blob: ae513a0f4d4a21096f9008715d09b811641f6643 [file]
//! Taps as WASI components: a connector runs inside the wrapper with exactly the access
//! its grant allows, enforced by the runtime rather than by trust.
//!
//! The grants file names each tap's domain, the hosts it may reach, its ceiling, and the
//! environment variable that holds the tracker's credential. The host here builds a
//! component store with no filesystem, no sockets and no environment, links the tap
//! world of `wit/tap.wit` to the memory service, and routes the tap's outbound HTTP
//! through one hook: a host outside the grant is refused, a label above the ceiling is
//! refused, and the credential goes on as a bearer token so the tap never holds it.
//! Everything the tap writes is a proposal in the owner's domain, under the tap's own
//! author: a task born proposed, or an entry at raw tier. The one thing it reads is the
//! handoffs of its domain's completed tasks from upstream, under its ceiling, to post
//! them back.
use dm_core::{Confidence, EntryType};
use dm_policy::TapGrant;
use dm_server::{Memory, WriteRequest};
use std::future::Future;
use std::path::Path;
use std::sync::Arc;
use wasmtime::component::{Component, HasSelf, Linker, ResourceTable};
use wasmtime::{Config, Engine, Store};
use wasmtime_wasi::{WasiCtx, WasiCtxBuilder, WasiCtxView, WasiView};
use wasmtime_wasi_http::{
RequestOptions, WasiBody, WasiHttpCtx, WasiHttpCtxView, WasiHttpHooks, WasiHttpView,
};
#[allow(missing_docs)] // generated from wit/tap.wit, which carries the documentation
mod bindings;
pub mod fetch;
pub mod pinned;
pub mod tool;
use bindings::durable_memory::tap::memory::{Done, Filed, Host, Proposal, Task};
use bindings::Tap;
/// What can go wrong running a tap.
#[derive(Debug, thiserror::Error)]
pub enum Error {
/// The component will not load, link or run.
#[error("tap: {0}")]
Runtime(String),
/// The tap itself reported failure.
#[error("tap reported: {0}")]
Tap(String),
/// The tap gave up after the host refused what its grant does not allow.
#[error("refused: {0}; the tap then reported: {1}")]
Refused(String, String),
/// The memory service.
#[error(transparent)]
Memory(#[from] dm_server::Error),
}
impl From<wasmtime::Error> for Error {
fn from(e: wasmtime::Error) -> Self {
Error::Runtime(format!("{e:#}"))
}
}
/// The outbound hook: the grant's hosts, the ceiling, and the credential. What it
/// refused is kept, so a run the tap gives up on can say why.
struct Hooks {
hosts: Vec<String>,
credential: Option<http::HeaderValue>,
memory: Arc<Memory>,
ceiling: dm_core::Label,
refused: Vec<String>,
}
/// Whether an authority (`host` or `host:port`) is one the grant names. A grant entry
/// with a port is that port only; one without is any port on that host.
#[must_use]
pub fn host_allowed(hosts: &[String], authority: &str) -> bool {
let a = authority.to_ascii_lowercase();
hosts.iter().any(|h| {
let h = h.trim().to_ascii_lowercase();
!h.is_empty() && (a == h || a.rsplit_once(':').is_some_and(|(host, _)| host == h))
})
}
/// The host part of an authority, without a port; a bracketed IPv6 literal keeps its
/// brackets.
fn host_of(authority: &str) -> &str {
if authority.starts_with('[') {
return authority
.split(']')
.next()
.map_or(authority, |h| &authority[..=h.len()]);
}
authority.rsplit_once(':').map_or(authority, |(h, _)| h)
}
/// Whether a host is this machine, where plaintext never leaves the kernel.
fn is_loopback(host: &str) -> bool {
matches!(host, "localhost" | "127.0.0.1" | "[::1]") || host.starts_with("127.")
}
/// The one rule for an outbound call: a host the grant names, and TLS unless the host
/// is loopback, since the credential rides on the request. Why not, in words, when not.
///
/// # Errors
///
/// The reason the call is refused.
pub fn allowed(hosts: &[String], scheme: Option<&str>, authority: &str) -> Result<(), String> {
if !host_allowed(hosts, authority) {
return Err(format!("the grant does not allow {authority}"));
}
let scheme = scheme.unwrap_or("https");
if scheme != "https" && !is_loopback(host_of(authority)) {
return Err(format!(
"{scheme} to {authority} would send the credential in the clear; https only"
));
}
Ok(())
}
type HttpError = wasmtime_wasi_http::Error;
type SendResult = Result<
(
http::Response<WasiBody>,
Box<dyn Future<Output = Result<(), HttpError>> + Send>,
),
HttpError,
>;
impl WasiHttpHooks for Hooks {
fn send_request(
&mut self,
mut request: http::Request<WasiBody>,
options: Option<RequestOptions>,
_fut: Box<dyn Future<Output = Result<(), HttpError>> + Send>,
) -> Box<dyn Future<Output = SendResult> + Send> {
let authority = request
.uri()
.authority()
.map(|a| a.as_str().to_string())
.unwrap_or_default();
let scheme = request.uri().scheme_str();
if let Err(why) = allowed(&self.hosts, scheme, &authority) {
self.refused.push(why);
return Box::new(async { Err(HttpError::HttpRequestDenied) });
}
let label = self.memory.label();
if label > self.ceiling {
self.refused.push(format!(
"the tap's label {label} is above its ceiling {}",
self.ceiling
));
return Box::new(async { Err(HttpError::HttpRequestDenied) });
}
if let Some(c) = &self.credential {
request
.headers_mut()
.insert(http::header::AUTHORIZATION, c.clone());
}
Box::new(async move {
use http_body_util::BodyExt;
let (res, io) = wasmtime_wasi_http::default_send_request(request, options).await?;
Ok((
res.map(BodyExt::boxed_unsync),
Box::new(io) as Box<dyn Future<Output = _> + Send>,
))
})
}
}
/// The store's state: WASI with nothing granted, HTTP through the hook, and the memory.
struct State {
wasi: WasiCtx,
http: WasiHttpCtx,
table: ResourceTable,
hooks: Hooks,
memory: Arc<Memory>,
domain: String,
limits: wasmtime::StoreLimits,
/// The tasks `completed` handed out this run: the only ones `posted` may name.
handed: std::collections::HashSet<String>,
}
impl WasiView for State {
fn ctx(&mut self) -> WasiCtxView<'_> {
WasiCtxView {
ctx: &mut self.wasi,
table: &mut self.table,
}
}
}
impl WasiHttpView for State {
fn http(&mut self) -> WasiHttpCtxView<'_> {
WasiHttpCtxView {
hooks: &mut self.hooks,
table: &mut self.table,
ctx: &mut self.http,
}
}
}
impl Host for State {
async fn file_task(&mut self, t: Task) -> wasmtime::Result<Result<Filed, String>> {
let spec = dm_ledger::TaskSpec {
domain: self.domain.clone(),
title: t.title,
body: t.body,
requirements: t.requirements,
depends_on: Vec::new(),
origin: Some(t.origin),
upstream_event: Some(t.upstream_event),
created_by: String::new(),
// upstream content is as sensitive as the domain says, never less
classification: self
.memory
.grants()
.domains
.get(&self.domain)
.map_or(dm_core::Label::Public, |d| d.default_classification),
priority: t.priority,
};
Ok(match self.memory.create_task(spec).await {
Ok(c) => Ok(Filed {
id: c.task.id,
deduplicated: c.deduplicated,
}),
Err(e) => Err(e.to_string()),
})
}
async fn completed(&mut self) -> wasmtime::Result<Result<Vec<Done>, String>> {
// the ceiling bounds what the tap may read as it bounds what it may send: a
// handoff above it is not handed out, and the tap's label stays under it
let ceiling = self.hooks.ceiling;
Ok(match self.memory.unposted(&self.domain, ceiling).await {
Ok(done) => Ok(done
.into_iter()
.map(|(task, handoff)| Done {
id: {
self.handed.insert(task.id.clone());
task.id
},
title: task.spec.title,
origin: task.spec.origin.unwrap_or_default(),
upstream_event: task.spec.upstream_event.unwrap_or_default(),
handoff: handoff.front.id.clone(),
text: handoff.body,
})
.collect()),
Err(e) => Err(e.to_string()),
})
}
async fn posted(&mut self, id: String, to: String) -> wasmtime::Result<Result<(), String>> {
// a tap says where it posted what it was handed, and nothing about the rest
if !self.handed.contains(&id) {
return Ok(Err(format!("{id} was not handed to this run")));
}
Ok(self
.memory
.post(&id, &to, &self.domain, self.hooks.ceiling)
.await
.map(|_| ())
.map_err(|e| e.to_string()))
}
async fn propose(&mut self, p: Proposal) -> wasmtime::Result<Result<String, String>> {
let entry_type = match p.kind.parse::<EntryType>() {
Ok(k) => k,
Err(e) => return Ok(Err(format!("kind: {e}"))),
};
let req = WriteRequest {
domain: Some(self.domain.clone()),
entry_type,
title: Some(p.title),
body: p.body,
source: p.source,
confidence: Some(Confidence::Low),
..WriteRequest::default()
};
Ok(match self.memory.write(req).await {
Ok(w) => Ok(w.id),
Err(e) => Err(e.to_string()),
})
}
}
/// What one run did, as the tap reported it, and what the host refused on the way.
#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)]
pub struct Outcome {
/// Tasks filed anew.
pub filed: u32,
/// Tasks already filed for their origin and event.
pub deduplicated: u32,
/// Entries proposed.
pub proposed: u32,
/// Handoffs posted back upstream.
pub posted: u32,
/// The tap's one-line note to the owner.
pub note: String,
/// Outbound calls the host refused, in words, whether or not the tap went on.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub refused: Vec<String>,
}
/// What a run may spend.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Limits {
/// Wall clock for the whole run, waits on the network included.
pub wall: std::time::Duration,
/// Linear memory the component may grow to, in bytes.
pub memory: usize,
}
impl Default for Limits {
fn default() -> Self {
Limits {
wall: std::time::Duration::from_secs(120),
memory: 256 << 20,
}
}
}
pub(crate) fn engine() -> wasmtime::Result<Engine> {
let mut config = Config::new();
config.wasm_component_model(true);
// the epoch is what lets a busy guest be interrupted: a timer ticks it, the store
// yields to the executor on every tick, and the wall clock can then end the run
config.epoch_interruption(true);
Engine::new(&config)
}
/// How often the epoch ticks while a tap runs.
pub(crate) const EPOCH_TICK: std::time::Duration = std::time::Duration::from_millis(10);
/// Run a tap once. `memory` is opened by the caller under the tap's author;
/// `credential` is the value of the variable the grant names, if it was set.
///
/// # Errors
///
/// A component that will not load or link, a trap, a refused outbound call the tap did
/// not survive, or the tap's own reported failure.
pub async fn run(
memory: Arc<Memory>,
component: &Path,
grant: &TapGrant,
credential: Option<&str>,
config: &str,
limits: Limits,
) -> Result<Outcome, Error> {
let engine = engine()?;
let component = Component::from_file(&engine, component)
.map_err(|e| Error::Runtime(format!("{}: {e:#}", component.display())))?;
let mut linker: Linker<State> = Linker::new(&engine);
wasmtime_wasi::p2::add_to_linker_async(&mut linker)?;
wasmtime_wasi_http::p2::add_only_http_to_linker_async(&mut linker)?;
Tap::add_to_linker::<_, HasSelf<_>>(&mut linker, |s| s)?;
let credential = credential
.map(|c| http::HeaderValue::from_str(&format!("Bearer {c}")))
.transpose()
.map_err(|e| Error::Runtime(format!("credential: {e}")))?;
let state = State {
wasi: WasiCtxBuilder::new().build(),
http: WasiHttpCtx::new(),
table: ResourceTable::new(),
hooks: Hooks {
hosts: grant.hosts.clone(),
credential,
memory: memory.clone(),
ceiling: grant.ceiling,
refused: Vec::new(),
},
domain: grant.domain.clone(),
memory,
limits: wasmtime::StoreLimitsBuilder::new()
.memory_size(limits.memory)
.build(),
handed: std::collections::HashSet::new(),
};
let mut store = Store::new(&engine, state);
store.limiter(|s| &mut s.limits);
store.set_epoch_deadline(1);
store.epoch_deadline_async_yield_and_update(1);
let ticker = {
let engine = engine.clone();
tokio::spawn(async move {
loop {
tokio::time::sleep(EPOCH_TICK).await;
engine.increment_epoch();
}
})
};
let outcome = tokio::time::timeout(limits.wall, async {
let tap = Tap::instantiate_async(&mut store, &component, &linker).await?;
tap.call_run(&mut store, config).await
})
.await;
ticker.abort();
let result = match outcome {
Ok(r) => r?,
Err(_) => {
return Err(Error::Runtime(format!(
"the run exceeded its wall clock of {:?}",
limits.wall
)))
}
};
let refused = std::mem::take(&mut store.data_mut().hooks.refused);
match result {
Ok(r) => Ok(Outcome {
filed: r.filed,
deduplicated: r.deduplicated,
proposed: r.proposed,
posted: r.posted,
note: r.note,
refused,
}),
Err(e) if refused.is_empty() => Err(Error::Tap(e)),
Err(e) => Err(Error::Refused(refused.join("; "), e)),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn hosts_match_with_or_without_a_port_and_never_by_suffix() {
let hosts = vec!["api.github.com".to_string(), "127.0.0.1".to_string()];
assert!(host_allowed(&hosts, "api.github.com"));
assert!(host_allowed(&hosts, "API.GITHUB.COM:443"));
assert!(host_allowed(&hosts, "127.0.0.1:4321"));
assert!(!host_allowed(&hosts, "evil-api.github.com"));
assert!(!host_allowed(&hosts, "api.github.com.evil.example"));
assert!(!host_allowed(&hosts, "github.com"));
assert!(!host_allowed(&[], "api.github.com"));
assert!(!host_allowed(&[String::new()], ""));
}
#[test]
fn plaintext_is_for_loopback_only_and_a_granted_port_is_that_port() {
let gh = vec!["api.github.com".to_string()];
assert!(allowed(&gh, Some("https"), "api.github.com").is_ok());
assert!(
allowed(&gh, None, "api.github.com").is_ok(),
"no scheme defaults to https"
);
let err = allowed(&gh, Some("http"), "api.github.com").unwrap_err();
assert!(err.contains("https only"), "{err}");
assert!(allowed(&gh, Some("http"), "api.github.com:8080").is_err());
let local = vec![
"127.0.0.1".to_string(),
"localhost".to_string(),
"[::1]".to_string(),
];
assert!(allowed(&local, Some("http"), "127.0.0.1:4321").is_ok());
assert!(allowed(&local, Some("http"), "localhost:80").is_ok());
assert!(allowed(&local, Some("http"), "[::1]:8080").is_ok());
let pinned = vec!["127.0.0.1:4321".to_string()];
assert!(allowed(&pinned, Some("http"), "127.0.0.1:4321").is_ok());
assert!(allowed(&pinned, Some("http"), "127.0.0.1:4322")
.unwrap_err()
.contains("does not allow"));
assert_eq!(host_of("[::1]:8080"), "[::1]");
assert_eq!(host_of("a.b:1"), "a.b");
assert_eq!(host_of("a.b"), "a.b");
}
}