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
13 changes: 13 additions & 0 deletions crates/fractal-api-core/src/api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,7 @@ pub mod records;
mod write_principal;
mod native_delivery;
pub mod replication;
mod local_only;
mod runs;
pub mod sessions;
pub mod work;
Expand Down Expand Up @@ -1058,6 +1059,7 @@ pub fn router_for_transport(state: AppState, transport: ClientTransportBoundary,
(state.clone(), transport),
response_envelope,
))
.layer(from_fn_with_state(state.clone(), local_only::enforce))
.with_state(state)
}

Expand Down Expand Up @@ -6709,6 +6711,9 @@ pub fn remote_unavailable(host: &str) -> ApiError {

/// A known dial-out owner syncs outward but has no inbound owner transport.
pub fn remote_unavailable_for_owner(state: &AppState, host: &str) -> ApiError {
if state.local_only() {
return local_only::refusal(crate::config::NetworkOperation::PeerRelay);
}
let dial_out = if let Some(relay) = &state.client_relay {
relay.is_dial_out_owner(host)
} else {
Expand Down Expand Up @@ -8243,6 +8248,8 @@ pub fn overlay_seat_admission(state: &AppState, items: &mut [Value], history: bo
}

async fn health(State(state): State<AppState>) -> Result<Json<Value>, ApiError> {
let network_policy = crate::config::NetworkPolicy::for_state_dir(&state.state_dir);
let network_enabled = !network_policy.is_local_only();
let mut health = json!({
"status": "ready",
"node": state.node,
Expand All @@ -8251,6 +8258,12 @@ async fn health(State(state): State<AppState>) -> Result<Json<Value>, ApiError>
"isolation": isolation_name(fractal_runtime::isolation_mode()),
"store_index": state.store.index().map_err(ApiError::internal)?,
"security": "trusted-network-no-tls-no-acls",
"network_policy": {
"mode": network_policy,
"fleet_join": network_enabled,
"replication": network_enabled,
"peer_relay": network_enabled,
},
"features": {"owned_sets":1,"seat_rollout":1,"seat_rollout_manual":1,"bounded_legacy_events":1,"person_directive_note":1,"declared_admission":1,"declared_attention":1,"github_plugin":2},
});
// How far this member's messages stream has come. Absent when it holds no shard, so a
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ fn removed_legacy_handlers_are_absent_from_api_and_its_route_families() {
("client_reads", /* sqlite-large-value: allow-for-now lv-h7507c9c06532 owner=fractal_api_core::api::legacy_handler_guard_tests TODO=trace-statement-and-value-representation */ include_str!("client_reads.rs")),
("fleet", /* sqlite-large-value: allow-for-now lv-hd82cb2e4adf3 owner=fractal_api_core::api::legacy_handler_guard_tests TODO=trace-statement-and-value-representation */ include_str!("fleet.rs")),
("launches", /* sqlite-large-value: allow-for-now lv-hb3cca75d21d1 owner=fractal_api_core::api::legacy_handler_guard_tests TODO=trace-statement-and-value-representation */ include_str!("launches.rs")),
("local_only", /* sqlite-large-value: allow-for-now lv-hc95cad153008 owner=fractal_api_core::api::legacy_handler_guard_tests TODO=trace-statement-and-value-representation */ include_str!("local_only.rs")),
("messages", /* sqlite-large-value: allow-for-now lv-h43c19aca2faa owner=fractal_api_core::api::legacy_handler_guard_tests TODO=trace-statement-and-value-representation */ include_str!("messages.rs")),
("native_delivery", /* sqlite-large-value: allow-for-now lv-h487b327aa403 owner=fractal_api_core::api::legacy_handler_guard_tests TODO=trace-statement-and-value-representation */ include_str!("native_delivery.rs")),
("operations", /* sqlite-large-value: allow-for-now lv-h5489ceb8d6c0 owner=fractal_api_core::api::legacy_handler_guard_tests TODO=trace-statement-and-value-representation */ include_str!("operations.rs")),
Expand Down
97 changes: 97 additions & 0 deletions crates/fractal-api-core/src/api/local_only.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
use super::*;
use crate::config::{NetworkOperation, NetworkPolicy};

impl AppState {
pub fn local_only(&self) -> bool {
NetworkPolicy::for_state_dir(&self.state_dir).is_local_only()
}
}

pub(super) fn refusal(operation: NetworkOperation) -> ApiError {
let refused = NetworkPolicy::LocalOnly.require(operation).unwrap_err();
ApiError {
status: StatusCode::FORBIDDEN,
code: refused.code.into(),
message: refused.to_string(),
details: Box::new(serde_json::Map::from_iter([
("network_policy".into(), json!(NetworkPolicy::LocalOnly)),
("operation".into(), json!(operation)),
])),
}
}

fn network_operation(path: &str) -> Option<NetworkOperation> {
if path.starts_with("/v1/internal/replication/") || path == "/v1/internal/replication-wake" {
Some(NetworkOperation::Replication)
} else if path.starts_with("/v1/internal/fleet/") {
Some(NetworkOperation::FleetJoin)
} else if path == crate::peer::CLIENT_READ_FORWARD_PATH
|| path == crate::peer::SEAT_FRESHNESS_INTERNAL_PATH
|| path == crate::peer::SEAT_FRESHNESS_READS_INTERNAL_PATH
{
Some(NetworkOperation::PeerRelay)
} else {
None
}
}

pub(super) async fn enforce(
State(state): State<AppState>,
request: Request<Body>,
next: Next,
) -> Response {
if state.local_only()
&& let Some(operation) = network_operation(request.uri().path())
{
return refusal(operation).into_response();
}
next.run(request).await
}

#[cfg(test)]
mod tests {
use super::*;

#[tokio::test]
async fn fixture_refuses_join_replication_and_peer_relay_before_body_or_store_effects() {
let root = tempfile::tempdir().unwrap();
let mut state = super::super::tests_shared::state(root.path());
state.state_dir = root.path().join(".fractal-instance/client-control-fixture/state/fractal");
assert!(state.local_only());
let before = state.store.index().unwrap();
let app = super::super::router(state.clone());
for (path, code) in [
("/v1/internal/fleet/redeem", "local-only-fleet-join-refused"),
("/v1/internal/fleet/pairing-admission", "local-only-fleet-join-refused"),
("/v1/internal/replication/export", "local-only-replication-refused"),
("/v1/internal/replication/receive", "local-only-replication-refused"),
("/v1/internal/replication/shard/messages/receive", "local-only-replication-refused"),
(crate::peer::CLIENT_READ_FORWARD_PATH, "local-only-peer-relay-refused"),
(crate::peer::SEAT_FRESHNESS_INTERNAL_PATH, "local-only-peer-relay-refused"),
] {
let response = app.clone().oneshot(Request::builder().method("POST").uri(path)
.body(Body::from("not even valid JSON")).unwrap()).await.unwrap();
assert_eq!(response.status(), StatusCode::FORBIDDEN, "{path}");
let bytes = axum::body::to_bytes(response.into_body(), usize::MAX).await.unwrap();
let value: Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(value["code"], code, "{path}: {value}");
assert_eq!(value["details"]["network_policy"], "local-only");
}
assert_eq!(state.store.index().unwrap(), before);
}

#[tokio::test]
async fn health_proves_fixture_network_policy_and_nonfixture_stays_fleet_capable() {
let root = tempfile::tempdir().unwrap();
let mut state = super::super::tests_shared::state(root.path());
let ordinary = super::super::health(State(state.clone())).await.unwrap().0;
assert_eq!(ordinary["network_policy"]["mode"], "fleet-capable");
state.state_dir = root.path().join(".st-instance/client-control-fixture/state/fractal");
let health = super::super::health(State(state.clone())).await.unwrap().0;
assert_eq!(health["network_policy"], json!({
"mode":"local-only", "fleet_join":false, "replication":false, "peer_relay":false,
}));
let error = super::super::remote_unavailable_for_owner(&state, "host/personal");
assert_eq!(error.code, "local-only-peer-relay-refused");
}
}
4 changes: 4 additions & 0 deletions crates/fractal-kernel/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ use crate::model::PlannerSpec;

pub mod changes;
mod client;
mod local_only;
pub use local_only::{CLIENT_CONTROL_FIXTURE, LocalOnlyRefusal, NetworkOperation, NetworkPolicy};

/// The per-host switch for seat lanes.
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
Expand Down Expand Up @@ -998,6 +1000,7 @@ impl Config {
let Some(file) = FleetFile::load(&self.state_dir)? else {
return Ok(());
};
self.network_policy().require(NetworkOperation::FleetJoin)?;
if let Some(configured) = &self.fleet_id {
anyhow::ensure!(
*configured == file.fleet_id,
Expand All @@ -1024,6 +1027,7 @@ impl Config {
}

pub fn validate(&self) -> Result<()> {
self.network_policy().validate_config(self)?;
if let Some(usage_limits) = &self.usage_limits {
usage_limits.validate()?;
}
Expand Down
143 changes: 143 additions & 0 deletions crates/fractal-kernel/src/config/local_only.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
//! The fixture instance is an isolation boundary, not an operator-toggleable fleet member.
use std::path::Path;

use serde::Serialize;

use super::Config;

pub const CLIENT_CONTROL_FIXTURE: &str = "client-control-fixture";

#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum NetworkPolicy {
FleetCapable,
LocalOnly,
}

#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum NetworkOperation {
FleetJoin,
Replication,
PeerRelay,
}

#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
pub struct LocalOnlyRefusal {
pub code: &'static str,
pub operation: NetworkOperation,
}

impl std::fmt::Display for LocalOnlyRefusal {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(formatter, "{}: the client-control-fixture instance is permanently local-only", self.code)
}
}

impl std::error::Error for LocalOnlyRefusal {}

impl NetworkPolicy {
/// The instance environment is set before threads start. Recognize its state path too so
/// an explicit config or service invocation cannot turn the fixture into a fleet member.
pub fn for_state_dir(state_dir: &Path) -> Self {
let instance = crate::instance::current();
Self::for_instance(instance.as_ref().map(crate::instance::Instance::name), state_dir)
}

fn for_instance(instance: Option<&str>, state_dir: &Path) -> Self {
let fixture_path = state_dir.ancestors().any(|path| {
path.file_name().is_some_and(|name| name == CLIENT_CONTROL_FIXTURE)
&& path.parent().and_then(Path::file_name).is_some_and(|name| {
name == ".st-instance" || name == ".fractal-instance"
})
});
if instance == Some(CLIENT_CONTROL_FIXTURE) || fixture_path {
Self::LocalOnly
} else {
Self::FleetCapable
}
}

pub fn is_local_only(self) -> bool {
self == Self::LocalOnly
}

pub fn require(self, operation: NetworkOperation) -> Result<(), LocalOnlyRefusal> {
if !self.is_local_only() {
return Ok(());
}
Err(LocalOnlyRefusal {
code: match operation {
NetworkOperation::FleetJoin => "local-only-fleet-join-refused",
NetworkOperation::Replication => "local-only-replication-refused",
NetworkOperation::PeerRelay => "local-only-peer-relay-refused",
},
operation,
})
}

pub fn validate_config(self, config: &Config) -> Result<(), LocalOnlyRefusal> {
if config.fleet_id.is_some() || config.fleet.is_some() {
self.require(NetworkOperation::FleetJoin)?;
}
if config.peer_listen.is_some() || config.shared_secret_file.is_some() {
self.require(NetworkOperation::Replication)?;
}
if !config.peers.is_empty() {
self.require(NetworkOperation::PeerRelay)?;
}
Ok(())
}
}

impl Config {
pub fn network_policy(&self) -> NetworkPolicy {
NetworkPolicy::for_state_dir(&self.state_dir)
}

pub fn local_only(&self) -> bool {
self.network_policy().is_local_only()
}
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn reserved_instance_cannot_escape_with_an_explicit_state_directory() {
assert_eq!(NetworkPolicy::for_instance(Some(CLIENT_CONTROL_FIXTURE), Path::new("/tmp/elsewhere")), NetworkPolicy::LocalOnly);
let home = tempfile::tempdir().unwrap();
for root in [".st-instance", ".fractal-instance"] {
assert_eq!(NetworkPolicy::for_instance(None, &home.path().join(root).join(CLIENT_CONTROL_FIXTURE).join("state/fractal")), NetworkPolicy::LocalOnly);
}
assert_eq!(NetworkPolicy::for_instance(Some("other"), Path::new("/tmp/state")), NetworkPolicy::FleetCapable);
assert_eq!(NetworkPolicy::for_instance(None, Path::new("/tmp/client-control-fixture/state")), NetworkPolicy::FleetCapable);
}

#[test]
fn each_network_operation_has_a_typed_refusal() {
for (operation, code) in [
(NetworkOperation::FleetJoin, "local-only-fleet-join-refused"),
(NetworkOperation::Replication, "local-only-replication-refused"),
(NetworkOperation::PeerRelay, "local-only-peer-relay-refused"),
] {
assert_eq!(NetworkPolicy::LocalOnly.require(operation), Err(LocalOnlyRefusal { code, operation }));
assert!(NetworkPolicy::FleetCapable.require(operation).is_ok());
}
}

#[test]
fn stale_or_injected_network_configuration_is_refused() {
let mut config = Config::default();
assert!(NetworkPolicy::LocalOnly.validate_config(&config).is_ok());
config.fleet_id = Some("personal-fleet".into());
assert_eq!(NetworkPolicy::LocalOnly.validate_config(&config).unwrap_err().operation, NetworkOperation::FleetJoin);
config.fleet_id = None;
config.peer_listen = Some("127.0.0.1:31313".into());
assert_eq!(NetworkPolicy::LocalOnly.validate_config(&config).unwrap_err().operation, NetworkOperation::Replication);
config.peer_listen = None;
config.peers.push(super::super::PeerConfig { name: "personal".into(), url: "http://127.0.0.1:31313".into() });
assert_eq!(NetworkPolicy::LocalOnly.validate_config(&config).unwrap_err().operation, NetworkOperation::PeerRelay);
}
}
7 changes: 7 additions & 0 deletions crates/fractal-peer/src/peer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,8 @@ mod seat_freshness;
pub use seat_freshness::*;
mod seat_freshness_reads;
pub use seat_freshness_reads::*;
#[cfg(test)]
mod local_only_tests;

/// The peer listener's shared state, with this node's daemon as the store.
type PeerState = smallclaims::sync::PeerState<MainBackend>;
Expand Down Expand Up @@ -531,6 +533,10 @@ impl ClientRelay {
}

pub fn from_config(config: &Config) -> Result<Option<Self>> {
if config.local_only() {
config.network_policy().validate_config(config)?;
return Ok(None);
}
let (Some(fleet), Some(secret)) = (
config.fleet_id.as_deref(),
config.shared_secret_file.as_deref(),
Expand Down Expand Up @@ -2104,6 +2110,7 @@ pub async fn conversation_gateway_relay(

/// Run the replication worker: sync this node's store, through its daemon, with the fleet.
pub async fn run_worker(config: Config, splice: RawSplice) -> Result<()> {
config.network_policy().require(crate::config::NetworkOperation::Replication)?;
config.validate()?;
if let Err(error) = crate::replication_worker_build::record(&config.state_dir) {
eprintln!("st replication-worker: WARNING: cannot report worker build: {error:#}");
Expand Down
42 changes: 42 additions & 0 deletions crates/fractal-peer/src/peer/local_only_tests.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
use super::*;
use crate::config::{LocalOnlyRefusal, NetworkOperation};

fn fixture_config(root: &Path) -> Config {
Config {
state_dir: root.join(".st-instance/client-control-fixture/state/fractal"),
..Config::default()
}
}

#[test]
fn fixture_never_constructs_a_peer_relay_even_without_fleet_configuration() {
let root = tempfile::tempdir().unwrap();
let mut config = fixture_config(root.path());
assert!(ClientRelay::from_config(&config).unwrap().is_none());
config.peers.push(PeerConfig { name: "personal".into(), url: "http://127.0.0.1:31313".into() });
let error = match ClientRelay::from_config(&config) {
Err(error) => error,
Ok(_) => panic!("fixture peer configuration must be refused"),
};
let refusal = error.downcast_ref::<LocalOnlyRefusal>().unwrap();
assert_eq!(refusal.operation, NetworkOperation::PeerRelay);
assert_eq!(refusal.code, "local-only-peer-relay-refused");
assert!(!config.state_dir.exists());
}

#[tokio::test]
async fn fixture_worker_refuses_before_creating_state_or_loading_keys() {
fn never_splice(_: axum::extract::ws::WebSocket, _: tokio::net::UnixStream)
-> std::pin::Pin<Box<dyn Future<Output = ()> + Send>>
{
panic!("a refused worker cannot start a peer transport");
}
let root = tempfile::tempdir().unwrap();
let config = fixture_config(root.path());
let state_dir = config.state_dir.clone();
let error = run_worker(config, never_splice).await.unwrap_err();
let refusal = error.downcast_ref::<LocalOnlyRefusal>().unwrap();
assert_eq!(refusal.operation, NetworkOperation::Replication);
assert_eq!(refusal.code, "local-only-replication-refused");
assert!(!state_dir.exists());
}
Loading
Loading