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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ members = [
"engine/packages/metrics",
"engine/packages/metrics-server",
"engine/packages/namespace",
"engine/packages/outbound-guard",
"engine/packages/pegboard",
"engine/packages/pegboard-envoy",
"engine/packages/pegboard-gateway",
Expand Down Expand Up @@ -500,6 +501,9 @@ members = [
[workspace.dependencies.namespace]
path = "engine/packages/namespace"

[workspace.dependencies.rivet-outbound-guard]
path = "engine/packages/outbound-guard"

[workspace.dependencies.pegboard]
path = "engine/packages/pegboard"

Expand Down
2 changes: 1 addition & 1 deletion docs/content/docs/debugging.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -199,7 +199,7 @@ Returns the configured provider settings per datacenter and the latest pool erro
}
```

`runner_pool_error` mirrors actor scheduling errors such as `serverless_http_error`, `serverless_connection_error`, and `serverless_stream_ended_early`.
`runner_pool_error` mirrors actor scheduling errors such as `serverless_http_error`, `serverless_connection_error`, `serverless_destination_blocked`, and `serverless_stream_ended_early`.

### Check Serverless Provider Health

Expand Down
8 changes: 8 additions & 0 deletions docs/content/docs/troubleshooting.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,14 @@ Rivet was unable to connect to your serverless endpoint. Check that:
- Your server is publicly reachable from the internet.
- There are no DNS or firewall issues blocking the connection.

### `serverless_destination_blocked`

The configured serverless URL points at a destination Rivet is not allowed to reach. Rivet dials serverless endpoints from inside its own network, so by default it refuses any destination that is not publicly routable: private ranges, link-local and cloud metadata addresses, carrier-grade NAT, and other reserved ranges. Check that:

- Your endpoint URL is publicly reachable, and is not an internal hostname or address.
- The URL scheme is `http` or `https`, and the URL does not embed credentials.
- If you are self-hosting and intentionally point runners at an address on your own network, set `outbound.allow_private_networks` to `true` in your engine config, or list the specific range in `outbound.allow_cidrs`.

### `serverless_stream_ended_early`

The connection to your serverless endpoint was terminated before the actor finished. This usually means your serverless function hit its execution time limit. Ensure that your Rivet provider's request lifespan is configured to match the max duration of your serverless platform.
Expand Down
68 changes: 68 additions & 0 deletions engine/artifacts/config-schema.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

11 changes: 11 additions & 0 deletions engine/packages/config/src/config/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ pub mod db;
pub mod guard;
pub mod logs;
pub mod metrics;
pub mod outbound;
pub mod pegboard;
pub mod pubsub;
pub mod pyroscope;
Expand All @@ -27,6 +28,7 @@ pub use db::Database;
pub use guard::*;
pub use logs::*;
pub use metrics::*;
pub use outbound::*;
pub use pegboard::*;
pub use pubsub::PubSub;
pub use pyroscope::*;
Expand Down Expand Up @@ -110,6 +112,9 @@ pub struct Root {

#[serde(default)]
pub pyroscope: Option<Pyroscope>,

#[serde(default)]
pub outbound: Option<Outbound>,
}

impl Default for Root {
Expand All @@ -130,6 +135,7 @@ impl Default for Root {
sqlite: None,
metrics: Default::default(),
pyroscope: None,
outbound: None,
}
}
}
Expand All @@ -145,6 +151,11 @@ impl Root {
self.api_peer.as_ref().unwrap_or(&DEFAULT)
}

pub fn outbound(&self) -> &Outbound {
static DEFAULT: LazyLock<Outbound> = LazyLock::new(Outbound::default);
self.outbound.as_ref().unwrap_or(&DEFAULT)
}

pub fn pegboard(&self) -> &Pegboard {
static DEFAULT: LazyLock<Pegboard> = LazyLock::new(Pegboard::default);
self.pegboard.as_ref().unwrap_or(&DEFAULT)
Expand Down
60 changes: 60 additions & 0 deletions engine/packages/config/src/config/outbound.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};

/// Policy for outbound HTTP requests to user-configured destinations, such as serverless runner
/// URLs.
///
/// These requests originate from inside the trusted engine network, so without restrictions a
/// caller who can configure a runner can reach internal-only services. The defaults deny every
/// non-globally-routable destination except loopback.
#[derive(Debug, Serialize, Deserialize, Clone, Default, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct Outbound {
/// Allow destinations that resolve to loopback addresses (127.0.0.0/8, ::1).
///
/// Enabled by default so local development against `http://localhost:...` works without
/// configuration.
pub allow_loopback: Option<bool>,
/// Allow destinations that resolve to private, link-local, shared (CGNAT), or otherwise
/// non-globally-routable addresses.
///
/// Self-hosted deployments that point runners at addresses on their own network, such as a
/// Docker Compose service name, need this enabled.
pub allow_private_networks: Option<bool>,
/// Allow plaintext `http://` destinations. When disabled only `https://` is permitted.
pub allow_insecure_scheme: Option<bool>,
/// Additional CIDRs that are always permitted, evaluated after `deny_cidrs`.
///
/// Use this to reach a specific internal service without opening up the whole private range.
pub allow_cidrs: Option<Vec<String>>,
/// Additional CIDRs that are always denied. Takes precedence over every allow rule.
pub deny_cidrs: Option<Vec<String>>,
/// Maximum number of redirects to follow. Every hop is re-checked against this policy.
pub max_redirects: Option<usize>,
}

impl Outbound {
pub fn allow_loopback(&self) -> bool {
self.allow_loopback.unwrap_or(true)
}

pub fn allow_private_networks(&self) -> bool {
self.allow_private_networks.unwrap_or(false)
}

pub fn allow_insecure_scheme(&self) -> bool {
self.allow_insecure_scheme.unwrap_or(true)
}

pub fn allow_cidrs(&self) -> &[String] {
self.allow_cidrs.as_deref().unwrap_or(&[])
}

pub fn deny_cidrs(&self) -> &[String] {
self.deny_cidrs.as_deref().unwrap_or(&[])
}

pub fn max_redirects(&self) -> usize {
self.max_redirects.unwrap_or(4)
}
}
20 changes: 20 additions & 0 deletions engine/packages/outbound-guard/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
[package]
name = "rivet-outbound-guard"
publish = false
version.workspace = true
authors.workspace = true
license.workspace = true
edition.workspace = true

[dependencies]
anyhow.workspace = true
ipnet.workspace = true
reqwest.workspace = true
rivet-config.workspace = true
thiserror.workspace = true
tokio.workspace = true
tracing.workspace = true
url.workspace = true

[dev-dependencies]
tokio = { workspace = true, features = ["macros", "rt-multi-thread"] }
82 changes: 82 additions & 0 deletions engine/packages/outbound-guard/src/client.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
use std::net::SocketAddr;
use std::sync::Arc;

use reqwest::dns::{Addrs, Name, Resolve, Resolving};

use crate::policy::{BlockReason, Policy};

/// A DNS resolver that drops every address the policy disallows.
///
/// Placing the check here rather than before the request closes the DNS rebinding window: reqwest
/// connects to exactly the addresses this returns, and it runs for every redirect hop as well as
/// the initial request.
#[derive(Debug)]
pub struct GuardedResolver {
policy: Arc<Policy>,
}

impl GuardedResolver {
pub fn new(policy: Arc<Policy>) -> Self {
GuardedResolver { policy }
}
}

impl Resolve for GuardedResolver {
fn resolve(&self, name: Name) -> Resolving {
let policy = self.policy.clone();
let host = name.as_str().to_string();

Box::pin(async move {
// The port is discarded by the connector, which substitutes the real one.
let resolved = tokio::net::lookup_host((host.as_str(), 0))
.await
.map_err(|err| {
tracing::debug!(%host, ?err, "failed to resolve outbound host");
Box::new(BlockReason::ResolutionFailed { host: host.clone() })
as Box<dyn std::error::Error + Send + Sync>
})?
.map(|addr| addr.ip())
.collect::<Vec<_>>();

let addrs = policy
.filter_addrs(&host, resolved)?
.into_iter()
.map(|ip| SocketAddr::new(ip, 0))
.collect::<Vec<_>>();

Ok(Box::new(addrs.into_iter()) as Addrs)
})
}
}

/// Build the redirect policy a guarded client must be configured with.
///
/// Every hop is re-checked, so a destination cannot bounce the engine somewhere it was not
/// allowed to reach directly.
pub fn redirect_policy(policy: Arc<Policy>) -> reqwest::redirect::Policy {
let max_redirects = policy.max_redirects();

reqwest::redirect::Policy::custom(move |attempt| {
if attempt.previous().len() >= max_redirects {
return attempt.error(BlockReason::TooManyRedirects { max: max_redirects });
}

match policy.check_url(attempt.url()) {
Ok(()) => attempt.follow(),
Err(reason) => {
tracing::debug!(url = %attempt.url(), %reason, "blocked outbound redirect");
attempt.error(reason)
}
}
})
}

/// Recover the [`BlockReason`] that caused a request to fail, if the policy is what stopped it.
///
/// The reason is buried in the source chain of a `reqwest::Error`, so callers that want to tell
/// "the destination is not allowed" apart from "the destination is down" have to walk it.
pub fn block_reason(err: &anyhow::Error) -> Option<BlockReason> {
err.chain()
.find_map(|err| err.downcast_ref::<BlockReason>())
.cloned()
}
Loading
Loading