Share owned host API state between HTTP hooks and native pages

This commit is contained in:
2026-11-07 00:23:57 +00:00
parent 45d099c3e4
commit c91ecbf7a4
14 changed files with 261 additions and 133 deletions
+7 -5
View File
@@ -1,7 +1,7 @@
# Host API and host pages
`handle(&Api, Caller, Request<Bytes>)` answers requests without a transport.
The later `wasi:http` hook supplies the caller from the process table.
The `wasi:http` hook supplies the caller from the process table.
It must not accept caller identity from headers, environment variables, or JSON.
Reads are open to every caller.
JSON routes return 200 on success.
@@ -11,11 +11,13 @@ Conflicts return 409. The filing budget returns 429.
## Host listener
`sausage run WORLD` starts the console and the host listener with one shared `Arc<Cred>`.
The listener owns its passkey connection and handles requests on a dedicated thread.
The console receives the same credentials store, including its decision notifications.
`sausage run WORLD` creates one `Cred`, one `Arc<Api>`, and one process runtime.
The API owns its passkey connection and retains ceremony and rate-limit state.
The hook and the host listener share that API.
The listener handles requests on a dedicated thread.
The console shares the credential store, including its decision notifications.
After the listener binds, the runtime denies its port on every local address.
The listener stops when the console returns.
No protected harness or process module needs a change.
Options on `sausage run`:
+3 -3
View File
@@ -15,7 +15,7 @@ fn choice(body: &Value) -> Result<(Decision, Overrides)> {
}
Ok((decision, overrides))
}
fn verify(ctx: &Api<'_>, body: &Value) -> Result<Verified> {
fn verify(ctx: &Api, body: &Value) -> Result<Verified> {
ctx.passkeys
.lock()
.map_err(|_| Error(500, "storage"))?
@@ -25,7 +25,7 @@ fn verify(ctx: &Api<'_>, body: &Value) -> Result<Verified> {
)
.map_err(|_| Error(403, "assertion_failed"))
}
pub(super) fn field(ctx: &Api<'_>, item: &str, field: &str) -> Result<cred::items::Field> {
pub(super) fn field(ctx: &Api, item: &str, field: &str) -> Result<cred::items::Field> {
let field = ctx
.cred
.item_get(item)?
@@ -39,7 +39,7 @@ pub(super) fn field(ctx: &Api<'_>, item: &str, field: &str) -> Result<cred::item
Ok(field)
}
pub(super) fn route(
ctx: &Api<'_>,
ctx: &Api,
caller: &Caller,
method: &str,
path: &[&str],
+26 -8
View File
@@ -17,16 +17,34 @@ use std::{
time::{Duration, Instant},
};
pub struct Api<'a> {
pub struct Api {
pub cred: Arc<Cred>,
script: cred::quickjs::QuickJs,
pub passkeys: Mutex<Passkeys<'a>>,
pub passkeys: Mutex<Passkeys<rusqlite::Connection>>,
pub entry: EntryKey,
requests: Mutex<HashMap<u32, (Instant, u32)>>,
}
impl<'a> Api<'a> {
impl Api {
/// Own the connection and ceremony state shared by both transports.
pub fn open(
cred: Arc<Cred>,
host_dir: &std::path::Path,
rp_id: &str,
origin: &str,
) -> anyhow::Result<Self> {
let db = rusqlite::Connection::open(host_dir.join("cred.sqlite"))?;
Ok(Self::new(
cred,
Passkeys::new(db, rp_id, origin)?,
EntryKey::open(&host_dir.join("entry.key"))?,
))
}
/// Passkeys owns the RP configuration. Its database must remain host-only.
pub fn new(cred: impl Into<Arc<Cred>>, passkeys: Passkeys<'a>, entry: EntryKey) -> Self {
pub fn new(
cred: impl Into<Arc<Cred>>,
passkeys: Passkeys<rusqlite::Connection>,
entry: EntryKey,
) -> Self {
Self {
cred: cred.into(),
script: cred::quickjs::QuickJs::default(),
@@ -113,10 +131,10 @@ fn request_view(request: cred::requests::Request) -> Result<Value> {
}
/// Handle a request without a network transport. Errors contain only static error kinds.
pub async fn handle(ctx: &Api<'_>, caller: Caller, request: Request<Bytes>) -> Response<Bytes> {
pub async fn handle(ctx: &Api, caller: Caller, request: Request<Bytes>) -> Response<Bytes> {
respond(ctx, caller, request)
}
fn respond(ctx: &Api<'_>, caller: Caller, request: Request<Bytes>) -> Response<Bytes> {
fn respond(ctx: &Api, caller: Caller, request: Request<Bytes>) -> Response<Bytes> {
let result = if request.method() == http::Method::GET {
match pages::get(ctx, request.uri().path()) {
Ok(Some(response)) => return response,
@@ -143,7 +161,7 @@ fn respond(ctx: &Api<'_>, caller: Caller, request: Request<Bytes>) -> Response<B
.body(Bytes::from(serde_json::to_vec(&body).expect("JSON value")))
.expect("valid response")
}
fn route(ctx: &Api<'_>, caller: &Caller, request: Request<Bytes>) -> Result<Value> {
fn route(ctx: &Api, caller: &Caller, request: Request<Bytes>) -> Result<Value> {
let path: Vec<_> = request.uri().path().trim_matches('/').split('/').collect();
let method = request.method().as_str();
let body: Value = if request.body().is_empty() {
@@ -206,7 +224,7 @@ mod tests {
let db = rusqlite::Connection::open_in_memory()?;
let api = Api::new(
Cred::open(dir.path())?,
Passkeys::new(&db, "localhost", "http://localhost:8080")?,
Passkeys::new(db, "localhost", "http://localhost:8080")?,
EntryKey::open(&dir.path().join("entry.key"))?,
);
let caller = Caller::default();
+1 -1
View File
@@ -10,7 +10,7 @@ fn escape(text: &str) -> String {
.replace('"', "&quot;")
.replace('\'', "&#39;")
}
pub(super) fn get(ctx: &Api<'_>, path: &str) -> Result<Option<Response<Bytes>>> {
pub(super) fn get(ctx: &Api, path: &str) -> Result<Option<Response<Bytes>>> {
let parts: Vec<_> = path.trim_matches('/').split('/').collect();
let (mime, text) = match parts.as_slice() {
["app.js"] => (
+67 -65
View File
@@ -1,6 +1,6 @@
//! The host HTTP listener and console startup with one shared credentials store.
//! The host HTTP listener and console startup share one owned API and credentials store.
use super::{Api, respond};
use crate::cred::{Caller, Cred, entry::EntryKey, webauthn::Passkeys};
use crate::cred::{Caller, Cred};
use std::{net::SocketAddr, path::Path, sync::Arc};
#[derive(Clone, Debug, clap::Args)]
@@ -22,71 +22,58 @@ pub struct Listener {
}
impl Listener {
pub fn start(cred: Arc<Cred>, host_dir: &Path, options: Options) -> anyhow::Result<Self> {
let api = Arc::new(Api::open(
cred,
host_dir,
&options.api_rp_id,
&options.api_origin,
)?);
Self::start_with_api(api, options)
}
pub fn start_with_api(api: Arc<Api>, options: Options) -> anyhow::Result<Self> {
let server = Arc::new(
tiny_http::Server::http(options.api_listen)
.map_err(|_| anyhow::anyhow!("host API address unavailable"))?,
);
let serving = server.clone();
let host_dir = host_dir.to_owned();
let (ready, started) = std::sync::mpsc::sync_channel(1);
let thread = std::thread::spawn(move || {
let setup = || -> anyhow::Result<_> {
Ok((
rusqlite::Connection::open(host_dir.join("cred.sqlite"))?,
EntryKey::open(&host_dir.join("entry.key"))?,
))
};
let serve = || -> anyhow::Result<()> {
let (db, entry) = setup()?;
let passkeys = Passkeys::new(&db, &options.api_rp_id, &options.api_origin)?;
let api = Api::new(cred, passkeys, entry);
let _ = ready.send(true);
for mut request in serving.incoming_requests() {
let response = (|| -> anyhow::Result<_> {
let mut body = Vec::new();
request.as_reader().read_to_end(&mut body)?;
let request = http::Request::builder()
.method(request.method().as_str())
.uri(request.url())
.body(bytes::Bytes::from(body))?;
Ok(respond(
&api,
Caller {
program: "/host/browser".into(),
pid: std::process::id(),
conversations: Vec::new(),
},
request,
for mut request in serving.incoming_requests() {
let response = (|| -> anyhow::Result<_> {
let mut body = Vec::new();
request.as_reader().read_to_end(&mut body)?;
let request = http::Request::builder()
.method(request.method().as_str())
.uri(request.url())
.body(bytes::Bytes::from(body))?;
Ok(respond(
&api,
Caller {
program: "/host/browser".into(),
pid: std::process::id(),
conversations: Vec::new(),
},
request,
))
})();
let response = response.unwrap_or_else(|_| {
http::Response::builder()
.status(400)
.header("content-type", "application/json")
.body(bytes::Bytes::from_static(
b"{\"error\":\"invalid_request\",\"message\":\"invalid_request\"}",
))
})();
let response = response.unwrap_or_else(|_| {
http::Response::builder()
.status(400)
.header("content-type", "application/json")
.body(bytes::Bytes::from_static(
b"{\"error\":\"invalid_request\",\"message\":\"invalid_request\"}",
))
.expect("static response")
});
let content_type = response.headers()["content-type"].as_bytes();
let reply = tiny_http::Response::from_data(response.body().to_vec())
.with_status_code(response.status().as_u16())
.with_header(
tiny_http::Header::from_bytes(b"Content-Type", content_type)
.expect("static header"),
);
let _ = request.respond(reply);
}
Ok(())
};
if serve().is_err() {
let _ = ready.send(false);
.expect("static response")
});
let content_type = response.headers()["content-type"].as_bytes();
let reply = tiny_http::Response::from_data(response.body().to_vec())
.with_status_code(response.status().as_u16())
.with_header(
tiny_http::Header::from_bytes(b"Content-Type", content_type)
.expect("static header"),
);
let _ = request.respond(reply);
}
});
if !started.recv().unwrap_or(false) {
let _ = thread.join();
anyhow::bail!("host API initialization failed");
}
Ok(Self {
server,
thread: Some(thread),
@@ -105,7 +92,7 @@ impl Drop for Listener {
}
}
/// Preserve the console lifecycle while sharing Cred with the host page listener.
/// Preserve the console lifecycle with one API for guests and host pages.
pub async fn run_with_userland(
path: &Path,
userland: Option<&Path>,
@@ -113,12 +100,26 @@ pub async fn run_with_userland(
) -> anyhow::Result<()> {
let world = crate::world::World::open_with_userland(path, userland)?;
let cred = Arc::new(Cred::open(&world.host_dir)?);
let _listener = Listener::start(cred.clone(), &world.host_dir, options)?;
let runtime = crate::process::Runtime::new(crate::world::World {
root: world.root.clone(),
host_dir: world.host_dir.clone(),
processes: world.processes.clone(),
})?;
let api = Arc::new(Api::open(
cred.clone(),
&world.host_dir,
&options.api_rp_id,
&options.api_origin,
)?);
let runtime = crate::process::Runtime::new_with_hooks(
crate::world::World {
root: world.root.clone(),
host_dir: world.host_dir.clone(),
processes: world.processes.clone(),
},
cred.clone(),
crate::process::default_script(),
Some(api.clone()),
)?;
let listener = Listener::start_with_api(api, options)?;
runtime
.network_policy()
.deny_host_port(listener.address().port());
let services = crate::process::supervise(runtime.clone());
let result = tokio::task::spawn_blocking(move || -> anyhow::Result<()> {
tokio::runtime::Builder::new_current_thread()
@@ -146,5 +147,6 @@ pub async fn run_with_userland(
anyhow::anyhow!(text)
});
services.shutdown().await;
drop(listener);
result
}
+1 -1
View File
@@ -4,7 +4,7 @@ use crate::cred::{self, Caller, script::Script, types::FieldType};
use base64::{Engine, engine::general_purpose::STANDARD};
use serde_json::{Value, json};
pub(super) fn handle(ctx: &Api<'_>, caller: &Caller, action: &str, body: &Value) -> Result<Value> {
pub(super) fn handle(ctx: &Api, caller: &Caller, action: &str, body: &Value) -> Result<Value> {
let cred = &ctx.cred;
let host = string(body, "host")?;
let mut items = cred.item_list()?.into_iter().filter(|item| {
+11 -7
View File
@@ -155,17 +155,18 @@ struct Pending<T> {
deadline: Instant,
value: T,
}
pub struct Passkeys<'a> {
db: &'a Connection,
/// Own the connection for a shared API, or borrow it for a local caller.
pub struct Passkeys<D: std::borrow::Borrow<Connection>> {
db: D,
webauthn: Webauthn,
registrations: HashMap<String, Pending<(String, bool, PasskeyRegistration)>>,
assertions: HashMap<String, Pending<(Binding, PasskeyAuthentication)>>,
}
impl<'a> Passkeys<'a> {
pub fn new(db: &'a Connection, rp_id: &str, origin: &str) -> Result<Self> {
impl<D: std::borrow::Borrow<Connection>> Passkeys<D> {
pub fn new(db: D, rp_id: &str, origin: &str) -> Result<Self> {
let origin = Url::parse(origin)?;
let webauthn = WebauthnBuilder::new(rp_id, &origin)?.build()?;
db.execute_batch("CREATE TABLE IF NOT EXISTS passkey_credentials(id TEXT PRIMARY KEY,label TEXT NOT NULL,credential TEXT NOT NULL)")?;
db.borrow().execute_batch("CREATE TABLE IF NOT EXISTS passkey_credentials(id TEXT PRIMARY KEY,label TEXT NOT NULL,credential TEXT NOT NULL)")?;
Ok(Self {
db,
webauthn,
@@ -176,6 +177,7 @@ impl<'a> Passkeys<'a> {
/// Database errors leave the manager locked.
pub fn is_unlocked(&self) -> bool {
self.db
.borrow()
.query_row(
"SELECT NOT EXISTS(SELECT 1 FROM passkey_credentials)",
[],
@@ -187,6 +189,7 @@ impl<'a> Passkeys<'a> {
pub fn list(&self) -> Result<Vec<serde_json::Value>> {
let mut statement = self
.db
.borrow()
.prepare("SELECT id,label FROM passkey_credentials ORDER BY rowid")?;
let rows = statement.query_map([], |row| {
Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
@@ -201,6 +204,7 @@ impl<'a> Passkeys<'a> {
fn credentials(&self) -> Result<Vec<Passkey>> {
let mut q = self
.db
.borrow()
.prepare("SELECT credential FROM passkey_credentials")?;
q.query_map([], |r| r.get::<_, String>(0))?
.map(|r| Ok(serde_json::from_str(&r?)?))
@@ -261,7 +265,7 @@ impl<'a> Passkeys<'a> {
let credential = self
.webauthn
.finish_passkey_registration(&response, state)?;
self.db.execute(
self.db.borrow().execute(
"INSERT INTO passkey_credentials(id,label,credential) VALUES(?1,?2,?3)",
params![
serde_json::to_string(credential.cred_id())?,
@@ -313,7 +317,7 @@ impl<'a> Passkeys<'a> {
bail!("passkey counter did not increase");
}
credential.update_credential(&result);
self.db.execute(
self.db.borrow().execute(
"UPDATE passkey_credentials SET credential=?1 WHERE id=?2",
params![
serde_json::to_string(credential)?,
+5 -18
View File
@@ -6,7 +6,7 @@ use std::sync::Arc;
use wasmtime_wasi_http::{Error, RequestOptions};
use wasmtime_wasi_http::{WasiBody, WasiHttpHooks};
type Io = Box<dyn Future<Output = Result<(), Error>> + Send>;
pub type Router = fn(&Caller, Request<Bytes>) -> Response<Bytes>;
pub type Router = Arc<crate::api::Api>;
pub(super) struct Hooks {
pub caller: Caller,
pub cred: Arc<Cred>,
@@ -14,21 +14,6 @@ pub(super) struct Hooks {
pub policy: Arc<super::net::NetPolicy>,
pub router: Router,
}
pub fn whoami(caller: &Caller, request: Request<Bytes>) -> Response<Bytes> {
let (status, body) = if request.uri().path() == "/whoami" {
(200, serde_json::to_vec(caller).unwrap())
} else {
(
404,
serde_json::to_vec(&serde_json::json!({"error":"unknown host API route"})).unwrap(),
)
};
Response::builder()
.status(status)
.header("content-type", "application/json")
.body(Bytes::from(body))
.unwrap()
}
fn response(response: Response<Bytes>) -> (Response<WasiBody>, Io) {
(
response.map(|b| Full::new(b).map_err(|n| match n {}).boxed_unsync()),
@@ -51,7 +36,7 @@ impl WasiHttpHooks for Hooks {
let cred = self.cred.clone();
let script = self.script.clone();
let policy = self.policy.clone();
let router = self.router;
let router = self.router.clone();
Box::new(async move {
let (parts, mut body) = request.into_parts();
if parts
@@ -60,7 +45,9 @@ impl WasiHttpHooks for Hooks {
.is_some_and(|h| h.eq_ignore_ascii_case("host.sausage"))
{
let bytes = body.collect().await?.to_bytes();
return Ok(response(router(&caller, Request::from_parts(parts, bytes))));
return Ok(response(
crate::api::handle(&router, caller, Request::from_parts(parts, bytes)).await,
));
}
if !matches!(parts.uri.scheme_str(), Some("http" | "https")) {
while let Some(frame) = body.frame().await {
+17 -10
View File
@@ -164,7 +164,7 @@ impl Runtime {
}
fn with_compiler(world: World, compiler: Arc<cache::Compiler>) -> anyhow::Result<Arc<Self>> {
let cred = Arc::new(crate::cred::Cred::open(&world.host_dir)?);
Self::with_hooks(world, compiler, cred, default_script(), http::whoami)
Self::with_hooks(world, compiler, cred, default_script(), None)
}
/// Share credentials and the host API router with the host's other subsystems.
pub fn new_with_hooks(
@@ -173,21 +173,24 @@ impl Runtime {
script: Arc<dyn crate::cred::script::Script>,
router: Option<Router>,
) -> anyhow::Result<Arc<Self>> {
Self::with_hooks(
world,
cache::shared()?,
cred,
script,
router.unwrap_or(http::whoami),
)
Self::with_hooks(world, cache::shared()?, cred, script, router)
}
fn with_hooks(
world: World,
compiler: Arc<cache::Compiler>,
cred: Arc<crate::cred::Cred>,
script: Arc<dyn crate::cred::script::Script>,
router: http::Router,
router: Option<http::Router>,
) -> anyhow::Result<Arc<Self>> {
let router = match router {
Some(api) => api,
None => Arc::new(crate::api::Api::open(
cred.clone(),
&world.host_dir,
"localhost",
"http://localhost:8081",
)?),
};
let network = Arc::new(net::NetPolicy::open(&world.host_dir)?);
let engine = compiler.engine.clone();
let alive = Arc::new(AtomicBool::new(true));
@@ -215,6 +218,10 @@ impl Runtime {
cache::precompile(runtime.compiler.clone(), runtime.world.root.clone());
Ok(runtime)
}
/// Register native listener ports through the shared network policy.
pub fn network_policy(&self) -> &net::NetPolicy {
&self.network
}
/// Number of distinct program contents compiled by this runtime.
pub fn compiled_programs(&self) -> usize {
self.cache.lock().unwrap().len()
@@ -428,7 +435,7 @@ impl Runtime {
cred: self.cred.clone(),
script: self.script.clone(),
policy: self.network.clone(),
router: self.router,
router: self.router.clone(),
},
},
);