Merge v3-t29: wasi:http hook, credential injection, guest TCP/DNS with the admin rule, one shared host API (T29)
This commit is contained in:
Generated
+26
-8
@@ -3251,6 +3251,16 @@ dependencies = [
|
||||
"icu_properties",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "if-addrs"
|
||||
version = "0.15.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c0a05c691e1fae256cf7013d99dad472dc52d5543322761f83ec8d47eab40d2b"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "if_chain"
|
||||
version = "1.0.3"
|
||||
@@ -5020,6 +5030,11 @@ dependencies = [
|
||||
"hkdf",
|
||||
"hmac",
|
||||
"http",
|
||||
"http-body",
|
||||
"http-body-util",
|
||||
"hyper",
|
||||
"hyper-util",
|
||||
"if-addrs",
|
||||
"openssl",
|
||||
"p256",
|
||||
"rsa",
|
||||
@@ -5036,6 +5051,8 @@ dependencies = [
|
||||
"thiserror 2.0.21",
|
||||
"tiny_http",
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
"toml",
|
||||
"url",
|
||||
"uuid",
|
||||
"wasmtime",
|
||||
@@ -5044,6 +5061,7 @@ dependencies = [
|
||||
"wat",
|
||||
"webauthn-authenticator-rs",
|
||||
"webauthn-rs",
|
||||
"webpki-roots",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
@@ -5222,9 +5240,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "serde_spanned"
|
||||
version = "1.1.2"
|
||||
version = "1.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4e7523beb55eece201a2356bee0bbca0d1ab466c14c07703b2e0ee6d42cb0c2c"
|
||||
checksum = "9c187e0a369471c550af028cba56d4f4fa2a489ad28151ae95a4d340a2a8b782"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
@@ -6412,9 +6430,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "toml"
|
||||
version = "1.1.8+spec-1.1.0"
|
||||
version = "1.2.1+spec-1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "20489e00e4d8741d6be680764cc12e270655e375a20d1011e844a9c3379e678d"
|
||||
checksum = "d5755c51863cdac711f1956baf8eb9588e060755f7d3c4c979e70ea1e1f32cd7"
|
||||
dependencies = [
|
||||
"indexmap",
|
||||
"serde_core",
|
||||
@@ -6427,9 +6445,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "toml_datetime"
|
||||
version = "1.1.2+spec-1.1.0"
|
||||
version = "1.1.3+spec-1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2b86d767906c6c42421dcba507eb9d203e779497710a47782a224bb871653053"
|
||||
checksum = "173f25c883cd2176a2d8212155638c0a60d484a9d84546d270866f40f56b6e49"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
@@ -6457,9 +6475,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "toml_writer"
|
||||
version = "1.1.3+spec-1.1.0"
|
||||
version = "1.2.0+spec-1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "06bdbd8cfc056b8d2e2e85f29b56a3bdbecb527cef81eb39e3e7b98af4652770"
|
||||
checksum = "009dbb7700f11c55ef83aebbe4c0e9cf1f1ac0844bd8d6f00dc38f302026e13a"
|
||||
|
||||
[[package]]
|
||||
name = "tower"
|
||||
|
||||
@@ -53,6 +53,14 @@ webauthn-rs = "0.5"
|
||||
openssl = { version = "0.10.82", features = ["vendored"] }
|
||||
aws-lc-rs = "1.19.0"
|
||||
bytes = "1.12.1"
|
||||
tokio-rustls = "0.26.6"
|
||||
webpki-roots = "1.0.11"
|
||||
if-addrs = "0.15.0"
|
||||
toml = "1.2.1"
|
||||
http-body = "1.1.0"
|
||||
http-body-util = "0.1.5"
|
||||
hyper-util = { version = "0.1.21", features = ["tokio"] }
|
||||
hyper = { version = "1.12.0", features = ["client", "http1", "server"] }
|
||||
tiny_http = "0.12"
|
||||
|
||||
[dev-dependencies]
|
||||
|
||||
@@ -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`:
|
||||
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -10,7 +10,7 @@ fn escape(text: &str) -> String {
|
||||
.replace('"', """)
|
||||
.replace('\'', "'")
|
||||
}
|
||||
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
@@ -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
@@ -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| {
|
||||
|
||||
@@ -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)?,
|
||||
|
||||
+2
-3
@@ -10,14 +10,13 @@ pub enum Command {
|
||||
}
|
||||
|
||||
/// How to run it. `cwd` is a world path. `timeout` ends the command and all its processes.
|
||||
/// `conversation` is the harness conversation that runs the command. Its processes and their
|
||||
/// descendants carry it, so the host knows the caller (for example in the `wasi:http` hook).
|
||||
/// `conversations` carries the nearest-first conversation chain. Child processes inherit it.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct Options {
|
||||
pub cwd: String,
|
||||
pub env: Vec<(String, String)>,
|
||||
pub timeout: Option<Duration>,
|
||||
pub conversation: Option<String>,
|
||||
pub conversations: Vec<String>,
|
||||
}
|
||||
|
||||
/// The stream that one output chunk came from.
|
||||
|
||||
@@ -88,7 +88,7 @@ pub fn op_exec(
|
||||
};
|
||||
let (sender, receiver) = tokio::sync::mpsc::unbounded_channel();
|
||||
let task = tokio::spawn(async move {
|
||||
let result = exec.exec(command, Options { cwd, conversation, timeout: timeout.map(std::time::Duration::from_secs_f64), ..Default::default() }, &mut |stream, bytes| { let _ = sender.send(json!({"stream": if stream == Stream::Stdout {"stdout"} else {"stderr"}, "bytes": bytes})); }).await;
|
||||
let result = exec.exec(command, Options { cwd, conversations: conversation.into_iter().collect(), timeout: timeout.map(std::time::Duration::from_secs_f64), ..Default::default() }, &mut |stream, bytes| { let _ = sender.send(json!({"stream": if stream == Stream::Stdout {"stdout"} else {"stderr"}, "bytes": bytes})); }).await;
|
||||
let _ = sender.send(match result {
|
||||
Ok(exit) => json!({"exitCode": exit}),
|
||||
Err(e) => json!({"error": e.to_string()}),
|
||||
|
||||
+26
-7
@@ -92,12 +92,25 @@ deno_core::extension!(
|
||||
|
||||
/// Start the console with the process implementation supplied by the host.
|
||||
pub async fn run_with_exec(world: World, exec: Arc<dyn Exec>) -> anyhow::Result<()> {
|
||||
let cred = Arc::new(crate::cred::Cred::open(&world.host_dir)?);
|
||||
run_with_shared_credentials(world, exec, cred).await
|
||||
}
|
||||
async fn run_with_shared_credentials(
|
||||
world: World,
|
||||
exec: Arc<dyn Exec>,
|
||||
cred: Arc<crate::cred::Cred>,
|
||||
) -> anyhow::Result<()> {
|
||||
// Deno's unsynchronized operations require a current-thread Tokio runtime.
|
||||
tokio::task::spawn_blocking(move || {
|
||||
tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()?
|
||||
.block_on(run_module(world, exec, include_str!("../../js/bundle.js")))
|
||||
.block_on(run_module_with_credentials(
|
||||
world,
|
||||
exec,
|
||||
cred,
|
||||
include_str!("../../js/bundle.js"),
|
||||
))
|
||||
})
|
||||
.await?
|
||||
.map_err(|error: anyhow::Error| {
|
||||
@@ -232,13 +245,19 @@ pub async fn run(path: &Path) -> anyhow::Result<()> {
|
||||
/// Start the console with the shared process table and an optional userland bundle.
|
||||
pub async fn run_with_userland(path: &Path, userland: Option<&Path>) -> anyhow::Result<()> {
|
||||
let world = World::open_with_userland(path, userland)?;
|
||||
let runtime = crate::process::Runtime::new(World {
|
||||
root: world.root.clone(),
|
||||
host_dir: world.host_dir.clone(),
|
||||
processes: world.processes.clone(),
|
||||
})?;
|
||||
let cred = Arc::new(crate::cred::Cred::open(&world.host_dir)?);
|
||||
let runtime = crate::process::Runtime::new_with_hooks(
|
||||
World {
|
||||
root: world.root.clone(),
|
||||
host_dir: world.host_dir.clone(),
|
||||
processes: world.processes.clone(),
|
||||
},
|
||||
cred.clone(),
|
||||
crate::process::default_script(),
|
||||
None,
|
||||
)?;
|
||||
let services = crate::process::supervise(runtime.clone());
|
||||
let result = run_with_exec(world, Arc::new(runtime)).await;
|
||||
let result = run_with_shared_credentials(world, Arc::new(runtime), cred).await;
|
||||
services.shutdown().await;
|
||||
result
|
||||
}
|
||||
|
||||
@@ -0,0 +1,376 @@
|
||||
//! Per-process credential hook and pinned-address HTTP transport.
|
||||
//! Transport derives from Wasmtime 49's default sender, with a policy check before connect.
|
||||
use crate::cred::{Caller, Cred, inject::Outcome, script::Script};
|
||||
use http_body_util::{BodyExt, Full};
|
||||
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 = Arc<crate::api::Api>;
|
||||
pub(super) struct Hooks {
|
||||
pub caller: Caller,
|
||||
pub cred: Arc<Cred>,
|
||||
pub script: Arc<dyn Script>,
|
||||
pub policy: Arc<super::net::NetPolicy>,
|
||||
pub router: Router,
|
||||
}
|
||||
fn response(response: Response<Bytes>) -> (Response<WasiBody>, Io) {
|
||||
(
|
||||
response.map(|b| Full::new(b).map_err(|n| match n {}).boxed_unsync()),
|
||||
Box::new(async { Ok(()) }),
|
||||
)
|
||||
}
|
||||
impl WasiHttpHooks for Hooks {
|
||||
// Admit every scheme until the URI supplies the host name.
|
||||
fn is_supported_scheme(&mut self, _: &http::uri::Scheme) -> bool {
|
||||
true
|
||||
}
|
||||
fn send_request(
|
||||
&mut self,
|
||||
request: Request<WasiBody>,
|
||||
options: Option<RequestOptions>,
|
||||
fut: Io,
|
||||
) -> Box<dyn Future<Output = Result<(Response<WasiBody>, Io), Error>> + Send> {
|
||||
drop(fut);
|
||||
let caller = self.caller.clone();
|
||||
let cred = self.cred.clone();
|
||||
let script = self.script.clone();
|
||||
let policy = self.policy.clone();
|
||||
let router = self.router.clone();
|
||||
Box::new(async move {
|
||||
let (parts, mut body) = request.into_parts();
|
||||
if parts
|
||||
.uri
|
||||
.host()
|
||||
.is_some_and(|h| h.eq_ignore_ascii_case("host.sausage"))
|
||||
{
|
||||
let bytes = body.collect().await?.to_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 {
|
||||
frame?;
|
||||
}
|
||||
return Err(Error::HttpRequestUriInvalid);
|
||||
}
|
||||
let result =
|
||||
tokio::task::spawn_blocking(move || cred.inject(parts, &caller, script.as_ref()))
|
||||
.await;
|
||||
let mut parts = match result {
|
||||
Ok(Ok(Outcome::Forward(parts))) => parts,
|
||||
outcome => {
|
||||
// Drain without retaining uploads, so the guest can finish its output stream.
|
||||
while let Some(frame) = body.frame().await {
|
||||
frame?;
|
||||
}
|
||||
let (status, value) = match outcome {
|
||||
Ok(Ok(Outcome::Denied { item, request })) => (
|
||||
403,
|
||||
serde_json::json!({"message": format!("Access to {item} needs approval. Request {request} awaits a decision."), "item": item, "request": request}),
|
||||
),
|
||||
Ok(Err(e)) => (502, serde_json::json!({"error": e.to_string()})),
|
||||
_ => (
|
||||
502,
|
||||
serde_json::json!({"error": "credential worker failed"}),
|
||||
),
|
||||
};
|
||||
return Ok(response(
|
||||
Response::builder()
|
||||
.status(status)
|
||||
.header("content-type", "application/json")
|
||||
.body(Bytes::from(serde_json::to_vec(&value).unwrap()))
|
||||
.unwrap(),
|
||||
));
|
||||
}
|
||||
};
|
||||
let authority = parts.uri.authority().ok_or(Error::HttpRequestUriInvalid)?;
|
||||
if authority.as_str().contains('@') {
|
||||
return Err(Error::HttpRequestUriInvalid);
|
||||
}
|
||||
parts.headers.insert(
|
||||
http::header::HOST,
|
||||
authority
|
||||
.as_str()
|
||||
.parse()
|
||||
.map_err(|_| Error::HttpRequestUriInvalid)?,
|
||||
);
|
||||
for value in parts.headers.values_mut() {
|
||||
value.set_sensitive(true);
|
||||
}
|
||||
// Hyper otherwise discards unknown-length GET bodies before WASI finishes them.
|
||||
if !parts.headers.contains_key(http::header::CONTENT_LENGTH) {
|
||||
parts.headers.insert(
|
||||
http::header::TRANSFER_ENCODING,
|
||||
http::HeaderValue::from_static("chunked"),
|
||||
);
|
||||
}
|
||||
let (res, io) =
|
||||
send_request(Request::from_parts(parts, body), options, &policy).await?;
|
||||
Ok((res.map(BodyExt::boxed_unsync), Box::new(io) as Io))
|
||||
})
|
||||
}
|
||||
}
|
||||
use bytes::Bytes;
|
||||
use core::future::poll_fn;
|
||||
use core::pin::{Pin, pin};
|
||||
use core::task::{Poll, ready};
|
||||
use http::uri::Scheme;
|
||||
use http::{Request, Response};
|
||||
use http_body::Body;
|
||||
use std::time::Duration;
|
||||
use tokio::io::{AsyncRead, AsyncWrite};
|
||||
use tokio::net::TcpStream;
|
||||
|
||||
trait TokioStream: AsyncRead + AsyncWrite + Send + Sync + Unpin + 'static {
|
||||
fn boxed(self) -> Box<dyn TokioStream>
|
||||
where
|
||||
Self: Sized,
|
||||
{
|
||||
Box::new(self)
|
||||
}
|
||||
}
|
||||
impl<T> TokioStream for T where T: AsyncRead + AsyncWrite + Send + Sync + Unpin + 'static {}
|
||||
|
||||
/// Preserve Wasmtime's HTTP/1 transport and request deadlines, but connect only to checked addresses.
|
||||
/// The returned future drives the connection after the response headers arrive.
|
||||
async fn send_request<T: Body<Data = Bytes, Error = Error> + Send + 'static>(
|
||||
mut req: Request<T>,
|
||||
options: Option<RequestOptions>,
|
||||
policy: &super::net::NetPolicy,
|
||||
) -> Result<
|
||||
(
|
||||
Response<impl Body<Data = Bytes, Error = Error> + use<T>>,
|
||||
impl Future<Output = Result<(), Error>> + Send + use<T>,
|
||||
),
|
||||
Error,
|
||||
> {
|
||||
let uri = req.uri().clone();
|
||||
let authority = uri.authority().ok_or(Error::HttpRequestUriInvalid)?;
|
||||
let use_tls = uri.scheme() == Some(&Scheme::HTTPS);
|
||||
let authority = if authority.port().is_some() {
|
||||
authority.to_string()
|
||||
} else {
|
||||
let port = if use_tls { 443 } else { 80 };
|
||||
format!("{authority}:{port}")
|
||||
};
|
||||
|
||||
let connect_timeout = options
|
||||
.and_then(|r| r.connect_timeout)
|
||||
.unwrap_or(Duration::from_secs(600));
|
||||
|
||||
let first_byte_timeout = options
|
||||
.and_then(|r| r.first_byte_timeout)
|
||||
.unwrap_or(Duration::from_secs(600));
|
||||
|
||||
let between_bytes_timeout = options
|
||||
.and_then(|r| r.between_bytes_timeout)
|
||||
.unwrap_or(Duration::from_secs(600));
|
||||
|
||||
let stream = match tokio::time::timeout(connect_timeout, async {
|
||||
let addresses = policy.resolve(&uri).await?;
|
||||
TcpStream::connect(addresses.as_slice())
|
||||
.await
|
||||
.map_err(Error::Connect)
|
||||
})
|
||||
.await
|
||||
{
|
||||
Ok(stream) => stream?,
|
||||
Err(..) => return Err(Error::ConnectionTimeout),
|
||||
};
|
||||
let stream = if use_tls {
|
||||
// derived from https://github.com/rustls/rustls/blob/main/examples/src/bin/simpleclient.rs
|
||||
let root_cert_store = rustls::RootCertStore {
|
||||
roots: webpki_roots::TLS_SERVER_ROOTS.into(),
|
||||
};
|
||||
let config = rustls::ClientConfig::builder()
|
||||
.with_root_certificates(root_cert_store)
|
||||
.with_no_client_auth();
|
||||
let connector = tokio_rustls::TlsConnector::from(std::sync::Arc::new(config));
|
||||
let domain = tls_server_name(&authority)?;
|
||||
let stream = connector
|
||||
.connect(domain, stream)
|
||||
.await
|
||||
.map_err(Error::Tls)?;
|
||||
stream.boxed()
|
||||
} else {
|
||||
stream.boxed()
|
||||
};
|
||||
let (mut sender, conn) = tokio::time::timeout(
|
||||
connect_timeout,
|
||||
hyper::client::conn::http1::Builder::new().handshake(hyper_util::rt::TokioIo::new(stream)),
|
||||
)
|
||||
.await
|
||||
.map_err(|_| Error::ConnectionTimeout)??;
|
||||
|
||||
// Send origin-form URIs, because this connection does not use a proxy.
|
||||
*req.uri_mut() = http::Uri::builder()
|
||||
.path_and_query(
|
||||
req.uri()
|
||||
.path_and_query()
|
||||
.map(|p| p.as_str())
|
||||
.unwrap_or("/"),
|
||||
)
|
||||
.build()
|
||||
.expect("comes from valid request");
|
||||
|
||||
let send = async move {
|
||||
use core::task::Context;
|
||||
|
||||
/// Wrapper around [hyper::body::Incoming] used to
|
||||
/// account for request option timeout configuration
|
||||
struct IncomingResponseBody {
|
||||
incoming: hyper::body::Incoming,
|
||||
timeout: tokio::time::Interval,
|
||||
}
|
||||
impl http_body::Body for IncomingResponseBody {
|
||||
type Data = <hyper::body::Incoming as http_body::Body>::Data;
|
||||
type Error = Error;
|
||||
|
||||
fn poll_frame(
|
||||
mut self: Pin<&mut Self>,
|
||||
cx: &mut Context<'_>,
|
||||
) -> Poll<Option<Result<http_body::Frame<Self::Data>, Self::Error>>> {
|
||||
match Pin::new(&mut self.as_mut().incoming).poll_frame(cx) {
|
||||
Poll::Ready(None) => Poll::Ready(None),
|
||||
Poll::Ready(Some(Err(err))) => {
|
||||
let err = if err.is_timeout() {
|
||||
Error::HttpResponseTimeout
|
||||
} else {
|
||||
Error::from(err)
|
||||
};
|
||||
Poll::Ready(Some(Err(err)))
|
||||
}
|
||||
Poll::Ready(Some(Ok(frame))) => {
|
||||
self.timeout.reset();
|
||||
Poll::Ready(Some(Ok(frame)))
|
||||
}
|
||||
Poll::Pending => {
|
||||
ready!(self.timeout.poll_tick(cx));
|
||||
Poll::Ready(Some(Err(Error::ConnectionReadTimeout)))
|
||||
}
|
||||
}
|
||||
}
|
||||
fn is_end_stream(&self) -> bool {
|
||||
self.incoming.is_end_stream()
|
||||
}
|
||||
fn size_hint(&self) -> http_body::SizeHint {
|
||||
self.incoming.size_hint()
|
||||
}
|
||||
}
|
||||
|
||||
let res = tokio::time::timeout(first_byte_timeout, sender.send_request(req))
|
||||
.await
|
||||
.map_err(|_| Error::ConnectionReadTimeout)?
|
||||
.map_err(Error::from)?;
|
||||
// Tokio panics on an `interval` with a zero duration, so clamp it to a
|
||||
// 1ns timeout which is sort of effectively zero anyway.
|
||||
let between_bytes_timeout = if between_bytes_timeout == Duration::ZERO {
|
||||
Duration::new(0, 1)
|
||||
} else {
|
||||
between_bytes_timeout
|
||||
};
|
||||
let mut timeout = tokio::time::interval(between_bytes_timeout);
|
||||
timeout.reset();
|
||||
Ok(res.map(|incoming| IncomingResponseBody { incoming, timeout }))
|
||||
};
|
||||
let mut send = pin!(send);
|
||||
let mut conn = Some(conn);
|
||||
// Wait for response while driving connection I/O
|
||||
let res = poll_fn(|cx| match send.as_mut().poll(cx) {
|
||||
Poll::Ready(Ok(res)) => Poll::Ready(Ok(res)),
|
||||
Poll::Ready(Err(err)) => Poll::Ready(Err(err)),
|
||||
Poll::Pending => {
|
||||
// Response is not ready, poll `hyper` connection to drive I/O if it has not completed yet
|
||||
let Some(fut) = conn.as_mut() else {
|
||||
// `hyper` connection already completed
|
||||
return Poll::Pending;
|
||||
};
|
||||
let res = ready!(Pin::new(fut).poll(cx));
|
||||
// `hyper` connection completed, record that to prevent repeated poll
|
||||
conn = None;
|
||||
match res {
|
||||
// `hyper` connection has successfully completed, optimistically poll for response
|
||||
Ok(()) => send.as_mut().poll(cx),
|
||||
// `hyper` connection has failed, return the error
|
||||
Err(err) => Poll::Ready(Err(Error::from(err))),
|
||||
}
|
||||
}
|
||||
})
|
||||
.await?;
|
||||
Ok((res, async move {
|
||||
let Some(conn) = conn.take() else {
|
||||
// `hyper` connection has already completed
|
||||
return Ok(());
|
||||
};
|
||||
if let Err(err) = conn.await {
|
||||
if err.is_timeout() {
|
||||
return Err(Error::HttpResponseTimeout);
|
||||
}
|
||||
return Err(err.into());
|
||||
}
|
||||
Ok(())
|
||||
}))
|
||||
}
|
||||
|
||||
/// Resolve the rustls [`ServerName`] used for TLS certificate verification from
|
||||
/// an outbound request `authority`.
|
||||
///
|
||||
/// `authority` is in `host:port` form, where an IPv6 `host` is wrapped in
|
||||
/// brackets (for example `[::1]:443`). An IP literal is recognized by parsing
|
||||
/// the whole authority as a [`SocketAddr`]; this handles the bracketed IPv6
|
||||
/// form, which splitting on the first `:` would truncate. Anything else is
|
||||
/// treated as a host name, with the port stripped off before it is handed to
|
||||
/// rustls.
|
||||
///
|
||||
/// [`ServerName`]: rustls::pki_types::ServerName
|
||||
/// [`SocketAddr`]: std::net::SocketAddr
|
||||
pub(crate) fn tls_server_name(
|
||||
authority: &str,
|
||||
) -> Result<rustls::pki_types::ServerName<'static>, rustls::pki_types::InvalidDnsNameError> {
|
||||
use rustls::pki_types::ServerName;
|
||||
|
||||
if let Ok(addr) = authority.parse::<std::net::SocketAddr>() {
|
||||
return Ok(ServerName::from(addr.ip()));
|
||||
}
|
||||
let host = match authority.split_once(':') {
|
||||
Some((host, _port)) => host,
|
||||
None => authority,
|
||||
};
|
||||
Ok(ServerName::try_from(host)?.to_owned())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tls_server_name_tests {
|
||||
use super::tls_server_name;
|
||||
use rustls::pki_types::ServerName;
|
||||
|
||||
#[test]
|
||||
fn resolves_server_name_from_authority() {
|
||||
// Host names keep their host and drop the port.
|
||||
assert_eq!(
|
||||
tls_server_name("example.com:443").unwrap(),
|
||||
ServerName::try_from("example.com").unwrap()
|
||||
);
|
||||
assert_eq!(
|
||||
tls_server_name("example.com").unwrap(),
|
||||
ServerName::try_from("example.com").unwrap()
|
||||
);
|
||||
|
||||
// IP literals resolve to an `IpAddress` server name. The bracketed IPv6
|
||||
// form must not be truncated at the first `:`.
|
||||
assert_eq!(
|
||||
tls_server_name("127.0.0.1:80").unwrap(),
|
||||
ServerName::from(std::net::Ipv4Addr::LOCALHOST)
|
||||
);
|
||||
assert_eq!(
|
||||
tls_server_name("[::1]:443").unwrap(),
|
||||
ServerName::from(std::net::Ipv6Addr::LOCALHOST)
|
||||
);
|
||||
assert_eq!(
|
||||
tls_server_name("[2001:db8::1]:8443").unwrap(),
|
||||
ServerName::from("2001:db8::1".parse::<std::net::Ipv6Addr>().unwrap())
|
||||
);
|
||||
}
|
||||
}
|
||||
+101
-10
@@ -47,6 +47,13 @@ mod bindings {
|
||||
}
|
||||
use bindings::sausage::process::host::{Host, HostChild, Request, Stdio};
|
||||
mod cache;
|
||||
mod http;
|
||||
pub use http::Router;
|
||||
pub mod net;
|
||||
/// Credential scripts use T28's bounded QuickJS component.
|
||||
pub fn default_script() -> Arc<dyn crate::cred::script::Script> {
|
||||
Arc::new(crate::cred::quickjs::QuickJs::default())
|
||||
}
|
||||
pub use cache::configure as configure_cache;
|
||||
mod services;
|
||||
mod watcher;
|
||||
@@ -87,6 +94,10 @@ pub struct Runtime {
|
||||
entries: Mutex<HashMap<u32, Entry>>,
|
||||
next: AtomicU32,
|
||||
alive: Arc<AtomicBool>,
|
||||
cred: Arc<crate::cred::Cred>,
|
||||
script: Arc<dyn crate::cred::script::Script>,
|
||||
router: http::Router,
|
||||
pub network: Arc<net::NetPolicy>,
|
||||
}
|
||||
impl Drop for Runtime {
|
||||
fn drop(&mut self) {
|
||||
@@ -101,17 +112,18 @@ struct State {
|
||||
pid: u32,
|
||||
command: u32,
|
||||
group: u32,
|
||||
conversation: Option<String>,
|
||||
conversations: Vec<String>,
|
||||
stdio: Io,
|
||||
preview1: Option<sausage_vfs::preview1::State>,
|
||||
http: wasmtime_wasi_http::WasiHttpCtx,
|
||||
hooks: http::Hooks,
|
||||
}
|
||||
impl wasmtime_wasi_http::WasiHttpView for State {
|
||||
fn http(&mut self) -> wasmtime_wasi_http::WasiHttpCtxView<'_> {
|
||||
wasmtime_wasi_http::WasiHttpCtxView {
|
||||
ctx: &mut self.http,
|
||||
table: &mut self.table,
|
||||
hooks: Default::default(),
|
||||
hooks: &mut self.hooks,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -151,6 +163,35 @@ impl Runtime {
|
||||
Self::with_compiler(world, cache::Compiler::new(directory)?)
|
||||
}
|
||||
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(), None)
|
||||
}
|
||||
/// Share credentials and the host API router with the host's other subsystems.
|
||||
pub fn new_with_hooks(
|
||||
world: World,
|
||||
cred: Arc<crate::cred::Cred>,
|
||||
script: Arc<dyn crate::cred::script::Script>,
|
||||
router: Option<Router>,
|
||||
) -> anyhow::Result<Arc<Self>> {
|
||||
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: 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));
|
||||
let ticking = alive.clone();
|
||||
@@ -164,6 +205,10 @@ impl Runtime {
|
||||
let runtime = Arc::new(Self {
|
||||
world,
|
||||
engine,
|
||||
cred,
|
||||
script,
|
||||
router,
|
||||
network,
|
||||
alive,
|
||||
compiler,
|
||||
cache: Mutex::new(Default::default()),
|
||||
@@ -173,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()
|
||||
@@ -203,7 +252,7 @@ impl Runtime {
|
||||
for path in paths {
|
||||
for candidate in [path.clone(), format!("{path}.wasm")] {
|
||||
match self.world.root.stat(&candidate, true).await {
|
||||
Ok(stat) if !stat.directory => return Ok(candidate),
|
||||
Ok(stat) if !stat.directory => return self.canonical_program(candidate).await,
|
||||
Ok(_) => {}
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
|
||||
Err(e) => return Err(e.into()),
|
||||
@@ -212,6 +261,32 @@ impl Runtime {
|
||||
}
|
||||
anyhow::bail!("program not found: {name}")
|
||||
}
|
||||
async fn canonical_program(&self, mut path: String) -> anyhow::Result<String> {
|
||||
for _ in 0..40 {
|
||||
let mut prefix = String::new();
|
||||
let components: Vec<_> = path.trim_start_matches('/').split('/').collect();
|
||||
let mut target = None;
|
||||
for (index, component) in components.iter().enumerate() {
|
||||
prefix.push('/');
|
||||
prefix.push_str(component);
|
||||
if self.world.root.stat(&prefix, false).await?.symlink {
|
||||
let link = self.world.root.readlink(&prefix).await?;
|
||||
let parent = prefix.rsplit_once('/').unwrap().0;
|
||||
let resolved = sausage_vfs::resolve(parent, &link);
|
||||
target = Some(sausage_vfs::resolve(
|
||||
&resolved,
|
||||
&components[index + 1..].join("/"),
|
||||
));
|
||||
break;
|
||||
}
|
||||
}
|
||||
match target {
|
||||
Some(target) => path = target,
|
||||
None => return Ok(path),
|
||||
}
|
||||
}
|
||||
anyhow::bail!("too many program symlinks")
|
||||
}
|
||||
fn kill_command(&self, command: u32) {
|
||||
for entry in self.entries.lock().unwrap().values() {
|
||||
if entry.command == command {
|
||||
@@ -244,7 +319,7 @@ impl Runtime {
|
||||
parent: u32,
|
||||
command: u32,
|
||||
inherited_group: u32,
|
||||
conversation: Option<String>,
|
||||
conversations: Vec<String>,
|
||||
inherited_io: Option<Io>,
|
||||
) -> anyhow::Result<Started> {
|
||||
anyhow::ensure!(!request.argv.is_empty(), "empty argv");
|
||||
@@ -329,9 +404,14 @@ impl Runtime {
|
||||
.stdin(stdio.input.clone())
|
||||
.stdout(AsyncStdoutStream::new(8192, stdio.output.clone()))
|
||||
.stderr(AsyncStdoutStream::new(8192, stdio.error.clone()))
|
||||
.allow_tcp(false)
|
||||
.allow_tcp(true)
|
||||
.allow_udp(false)
|
||||
.allow_ip_name_lookup(false);
|
||||
.allow_ip_name_lookup(true);
|
||||
let policy = self.network.clone();
|
||||
wasi.socket_addr_check(move |address, _| {
|
||||
let allowed = policy.allows(address);
|
||||
Box::pin(async move { allowed })
|
||||
});
|
||||
let mut store = Store::new(
|
||||
&self.engine,
|
||||
State {
|
||||
@@ -342,10 +422,21 @@ impl Runtime {
|
||||
pid,
|
||||
command,
|
||||
group,
|
||||
conversation: conversation.clone(),
|
||||
conversations: conversations.clone(),
|
||||
stdio,
|
||||
preview1,
|
||||
http: wasmtime_wasi_http::WasiHttpCtx::new(),
|
||||
hooks: http::Hooks {
|
||||
caller: crate::cred::Caller {
|
||||
pid,
|
||||
program: executable,
|
||||
conversations: conversations.clone(),
|
||||
},
|
||||
cred: self.cred.clone(),
|
||||
script: self.script.clone(),
|
||||
policy: self.network.clone(),
|
||||
router: self.router.clone(),
|
||||
},
|
||||
},
|
||||
);
|
||||
store.epoch_deadline_async_yield_and_update(1);
|
||||
@@ -356,7 +447,7 @@ impl Runtime {
|
||||
argv: request.argv,
|
||||
cwd: request.cwd,
|
||||
state: "running".into(),
|
||||
conversation,
|
||||
conversation: conversations.first().cloned(),
|
||||
},
|
||||
);
|
||||
let (tx, code) = watch::channel(None);
|
||||
@@ -459,7 +550,7 @@ impl Host for State {
|
||||
self.pid,
|
||||
self.command,
|
||||
self.group,
|
||||
self.conversation.clone(),
|
||||
self.conversations.clone(),
|
||||
Some(self.stdio.clone()),
|
||||
)
|
||||
.await
|
||||
@@ -613,7 +704,7 @@ impl Exec for Arc<Runtime> {
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
options.conversation,
|
||||
options.conversations,
|
||||
None,
|
||||
);
|
||||
let mut child = if let Some(deadline) = deadline {
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
//! Host-side network policy. `<dir>/host/network.toml` accepts `deny = ["address:port"]`.
|
||||
//! Host listeners register their ports with `deny_host_port`. Guests cannot bind or connect there.
|
||||
use std::{
|
||||
collections::HashSet,
|
||||
net::{IpAddr, SocketAddr},
|
||||
path::Path,
|
||||
sync::RwLock,
|
||||
};
|
||||
|
||||
fn normalize(address: SocketAddr) -> SocketAddr {
|
||||
match address {
|
||||
SocketAddr::V6(v6) => v6
|
||||
.ip()
|
||||
.to_ipv4_mapped()
|
||||
.map(|ip| SocketAddr::new(ip.into(), v6.port()))
|
||||
.unwrap_or(address),
|
||||
_ => address,
|
||||
}
|
||||
}
|
||||
pub struct NetPolicy {
|
||||
denied: HashSet<SocketAddr>,
|
||||
local: HashSet<IpAddr>,
|
||||
ports: RwLock<HashSet<u16>>,
|
||||
}
|
||||
impl NetPolicy {
|
||||
pub fn open(host_dir: &Path) -> anyhow::Result<Self> {
|
||||
#[derive(serde::Deserialize, Default)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct Config {
|
||||
#[serde(default)]
|
||||
deny: Vec<SocketAddr>,
|
||||
}
|
||||
let config = match std::fs::read_to_string(host_dir.join("network.toml")) {
|
||||
Ok(text) => toml::from_str::<Config>(&text)?,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Config::default(),
|
||||
Err(e) => return Err(e.into()),
|
||||
};
|
||||
Ok(Self {
|
||||
denied: config.deny.into_iter().map(normalize).collect(),
|
||||
local: if_addrs::get_if_addrs()?
|
||||
.into_iter()
|
||||
.map(|a| a.ip())
|
||||
.collect(),
|
||||
ports: RwLock::default(),
|
||||
})
|
||||
}
|
||||
pub fn deny_host_port(&self, port: u16) {
|
||||
self.ports.write().unwrap().insert(port);
|
||||
}
|
||||
pub fn allows(&self, address: SocketAddr) -> bool {
|
||||
let address = normalize(address);
|
||||
let ip = address.ip();
|
||||
!self.denied.contains(&address)
|
||||
&& !(self.ports.read().unwrap().contains(&address.port())
|
||||
&& (ip.is_loopback() || ip.is_unspecified() || self.local.contains(&ip)))
|
||||
}
|
||||
pub(super) async fn resolve(
|
||||
&self,
|
||||
uri: &http::Uri,
|
||||
) -> wasmtime_wasi_http::Result<Vec<SocketAddr>> {
|
||||
use wasmtime_wasi_http::Error;
|
||||
let host = uri
|
||||
.host()
|
||||
.ok_or(Error::HttpRequestUriInvalid)?
|
||||
.trim_matches(['[', ']']);
|
||||
let port = uri
|
||||
.port_u16()
|
||||
.unwrap_or(if uri.scheme_str() == Some("https") {
|
||||
443
|
||||
} else {
|
||||
80
|
||||
});
|
||||
let addresses: Vec<_> = tokio::net::lookup_host((host, port))
|
||||
.await
|
||||
.map_err(|_| Error::DnsError {
|
||||
rcode: None,
|
||||
info_code: None,
|
||||
})?
|
||||
.collect();
|
||||
if addresses.is_empty() || addresses.iter().any(|a| !self.allows(*a)) {
|
||||
return Err(Error::HttpRequestDenied);
|
||||
}
|
||||
Ok(addresses)
|
||||
}
|
||||
}
|
||||
@@ -31,7 +31,7 @@ pub fn supervise(runtime: Arc<Runtime>) -> Supervisor {
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
None,
|
||||
Vec::new(),
|
||||
None,
|
||||
);
|
||||
let child = tokio::select! { _ = &mut stop => return, child = start => child };
|
||||
|
||||
+4
-4
@@ -15,7 +15,7 @@ fn caller() -> Caller {
|
||||
conversations: vec!["conversation".into()],
|
||||
}
|
||||
}
|
||||
async fn send(api: &Api<'_>, method: &str, path: &str, body: Value) -> Result<(u16, Value)> {
|
||||
async fn send(api: &Api, method: &str, path: &str, body: Value) -> Result<(u16, Value)> {
|
||||
let request = http::Request::builder()
|
||||
.method(method)
|
||||
.uri(path)
|
||||
@@ -31,7 +31,7 @@ async fn send(api: &Api<'_>, method: &str, path: &str, body: Value) -> Result<(u
|
||||
serde_json::from_slice(response.body())?,
|
||||
))
|
||||
}
|
||||
async fn ok(api: &Api<'_>, method: &str, path: &str, body: Value) -> Result<Value> {
|
||||
async fn ok(api: &Api, method: &str, path: &str, body: Value) -> Result<Value> {
|
||||
let (status, value) = send(api, method, path, body).await?;
|
||||
assert_eq!(status, 200, "unexpected API status");
|
||||
Ok(value)
|
||||
@@ -55,7 +55,7 @@ async fn public_routes_and_ssh() -> Result<()> {
|
||||
)?;
|
||||
let api = Api::new(
|
||||
cred,
|
||||
Passkeys::new(&db, "localhost", "http://localhost:8080")?,
|
||||
Passkeys::new(db, "localhost", "http://localhost:8080")?,
|
||||
EntryKey::open(&dir.path().join("entry.key"))?,
|
||||
);
|
||||
let who = ok(
|
||||
@@ -221,7 +221,7 @@ async fn ssh_rejects_ambiguous_items() -> Result<()> {
|
||||
item(&cred, "two")?;
|
||||
let api = Api::new(
|
||||
cred,
|
||||
Passkeys::new(&db, "localhost", "http://localhost:8080")?,
|
||||
Passkeys::new(db, "localhost", "http://localhost:8080")?,
|
||||
EntryKey::open(&dir.path().join("entry.key"))?,
|
||||
);
|
||||
assert_eq!(
|
||||
|
||||
+10
-9
@@ -21,7 +21,7 @@ fn caller() -> Caller {
|
||||
conversations: vec!["conversation".into()],
|
||||
}
|
||||
}
|
||||
async fn send(api: &Api<'_>, method: &str, path: &str, body: Value) -> Result<(u16, Value)> {
|
||||
async fn send(api: &Api, method: &str, path: &str, body: Value) -> Result<(u16, Value)> {
|
||||
let response = handle(
|
||||
api,
|
||||
caller(),
|
||||
@@ -37,12 +37,12 @@ async fn send(api: &Api<'_>, method: &str, path: &str, body: Value) -> Result<(u
|
||||
serde_json::from_slice(response.body())?,
|
||||
))
|
||||
}
|
||||
async fn ok(api: &Api<'_>, method: &str, path: &str, body: Value) -> Result<Value> {
|
||||
async fn ok(api: &Api, method: &str, path: &str, body: Value) -> Result<Value> {
|
||||
let (status, value) = send(api, method, path, body).await?;
|
||||
assert_eq!(status, 200, "unexpected API status");
|
||||
Ok(value)
|
||||
}
|
||||
async fn page(api: &Api<'_>, path: &str) -> Result<String> {
|
||||
async fn page(api: &Api, path: &str) -> Result<String> {
|
||||
let response = handle(
|
||||
api,
|
||||
caller(),
|
||||
@@ -62,7 +62,7 @@ async fn page(api: &Api<'_>, path: &str) -> Result<String> {
|
||||
fn item(cred: &Cred, name: &str) -> Result<cred::items::Item> {
|
||||
Ok(cred.create_item(serde_json::from_value(json!({"name":name,"rules":[],"transform_js":"","fields":[{"name":"first","field_type":"text"},{"name":"second","field_type":"text"}]}))?,&caller(),cred.unlocked()?)?)
|
||||
}
|
||||
async fn file(api: &Api<'_>, item: &str, key: &str) -> Result<String> {
|
||||
async fn file(api: &Api, item: &str, key: &str) -> Result<String> {
|
||||
let result = ok(
|
||||
api,
|
||||
"POST",
|
||||
@@ -72,7 +72,7 @@ async fn file(api: &Api<'_>, item: &str, key: &str) -> Result<String> {
|
||||
.await?;
|
||||
Ok(result["request"].as_str().unwrap().into())
|
||||
}
|
||||
async fn register(api: &Api<'_>) -> Result<WebauthnAuthenticator<SoftPasskey>> {
|
||||
async fn register(api: &Api) -> Result<WebauthnAuthenticator<SoftPasskey>> {
|
||||
let start: Challenge<CreationChallengeResponse> = serde_json::from_value(
|
||||
ok(
|
||||
api,
|
||||
@@ -94,7 +94,7 @@ async fn register(api: &Api<'_>) -> Result<WebauthnAuthenticator<SoftPasskey>> {
|
||||
Ok(auth)
|
||||
}
|
||||
async fn assertion(
|
||||
api: &Api<'_>,
|
||||
api: &Api,
|
||||
auth: &mut WebauthnAuthenticator<SoftPasskey>,
|
||||
path: &str,
|
||||
body: Value,
|
||||
@@ -114,7 +114,7 @@ async fn approvals_registration_and_revocation() -> Result<()> {
|
||||
let second = item(&cred, "review <script>not markup</script>")?;
|
||||
let api = Api::new(
|
||||
cred,
|
||||
Passkeys::new(&db, "localhost", ORIGIN)?,
|
||||
Passkeys::new(db, "localhost", ORIGIN)?,
|
||||
EntryKey::open(&dir.path().join("entry.key"))?,
|
||||
);
|
||||
page(&api, "/register").await?;
|
||||
@@ -314,7 +314,7 @@ async fn approvals_registration_and_revocation() -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn encrypt(api: &Api<'_>, public_key: Value) -> Result<Envelope> {
|
||||
async fn encrypt(api: &Api, public_key: Value) -> Result<Envelope> {
|
||||
use deno_core::{JsRuntime, RuntimeOptions};
|
||||
let script = handle(
|
||||
api,
|
||||
@@ -358,7 +358,7 @@ async fn encrypted_entry_uses_the_host_page_code() -> Result<()> {
|
||||
let item = item(&cred, "secret")?;
|
||||
let api = Api::new(
|
||||
cred,
|
||||
Passkeys::new(&db, "localhost", ORIGIN)?,
|
||||
Passkeys::new(db, "localhost", ORIGIN)?,
|
||||
EntryKey::open(&dir.path().join("entry.key"))?,
|
||||
);
|
||||
let mut auth = register(&api).await?;
|
||||
@@ -408,6 +408,7 @@ async fn encrypted_entry_uses_the_host_page_code() -> Result<()> {
|
||||
assert!(!api.cred.item_get(&item.id)?.fields[0].needs_value);
|
||||
ok(&api, "GET", &format!("/cred/items/{}", item.id), json!({})).await?;
|
||||
let master = std::fs::read(dir.path().join("cred.key"))?;
|
||||
let db = rusqlite::Connection::open(dir.path().join("cred.sqlite"))?;
|
||||
let cipher: Vec<u8> = db.query_row(
|
||||
"SELECT ciphertext FROM fields WHERE item=?",
|
||||
[&item.id],
|
||||
|
||||
@@ -42,7 +42,10 @@ fn fixture() -> (tempfile::TempDir, Cred, Caller, Item) {
|
||||
.unwrap();
|
||||
(dir, c, caller, item)
|
||||
}
|
||||
fn register(host: &mut Passkeys<'_>, auth: &mut WebauthnAuthenticator<SoftPasskey>) {
|
||||
fn register(
|
||||
host: &mut Passkeys<&rusqlite::Connection>,
|
||||
auth: &mut WebauthnAuthenticator<SoftPasskey>,
|
||||
) {
|
||||
let start = host.start_registration("test".into(), None).unwrap();
|
||||
let response = auth
|
||||
.do_registration(Url::parse("http://localhost:8000").unwrap(), start.options)
|
||||
@@ -50,7 +53,7 @@ fn register(host: &mut Passkeys<'_>, auth: &mut WebauthnAuthenticator<SoftPasske
|
||||
host.finish_registration(&start.id, response).unwrap();
|
||||
}
|
||||
fn proof(
|
||||
host: &mut Passkeys<'_>,
|
||||
host: &mut Passkeys<&rusqlite::Connection>,
|
||||
auth: &mut WebauthnAuthenticator<SoftPasskey>,
|
||||
binding: Binding,
|
||||
) -> webauthn::Verified {
|
||||
|
||||
@@ -16,7 +16,7 @@ impl Exec for FakeExec {
|
||||
options: Options,
|
||||
output: &mut (dyn for<'chunk> FnMut(Stream, &'chunk [u8]) + Send),
|
||||
) -> anyhow::Result<i32> {
|
||||
assert!(options.conversation.is_some(), "missing conversation id");
|
||||
assert!(!options.conversations.is_empty(), "missing conversation id");
|
||||
let Command::Shell(command) = command else {
|
||||
anyhow::bail!("expected shell command")
|
||||
};
|
||||
|
||||
@@ -0,0 +1,363 @@
|
||||
//! Real guest HTTP requests pass through caller identity, credential injection, and streaming transport.
|
||||
mod hook_git;
|
||||
mod hook_guest;
|
||||
use sausage::{
|
||||
cred::{
|
||||
self,
|
||||
items::{Field, Item, Rule},
|
||||
types::FieldType,
|
||||
},
|
||||
exec::{Command, Exec, Options},
|
||||
process::Runtime,
|
||||
world::World,
|
||||
};
|
||||
use std::sync::Arc;
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
struct Fake;
|
||||
impl cred::script::Script for Fake {
|
||||
fn evaluate(
|
||||
&self,
|
||||
_: &str,
|
||||
input: &str,
|
||||
_: Option<&str>,
|
||||
_: cred::script::Limits,
|
||||
) -> Result<String, cred::script::Error> {
|
||||
let context: serde_json::Value = serde_json::from_str(input).unwrap();
|
||||
Ok(serde_json::json!({"headers":[{"name":"authorization","value":context["credential"]["token"]}]}).to_string())
|
||||
}
|
||||
}
|
||||
async fn run(runtime: &Arc<Runtime>, url: String, chain: Vec<String>) -> String {
|
||||
let mut out = Vec::new();
|
||||
let code = runtime
|
||||
.exec(
|
||||
Command::Argv(vec!["/bin/guest.wasm".into(), url, "GET".into()]),
|
||||
Options {
|
||||
cwd: "/".into(),
|
||||
conversations: chain,
|
||||
timeout: Some(std::time::Duration::from_secs(30)),
|
||||
..Default::default()
|
||||
},
|
||||
&mut |_, b| out.extend_from_slice(b),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(code, 0);
|
||||
String::from_utf8(out).unwrap()
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn identity_injection_denial_and_ambiguity() {
|
||||
let guest = hook_guest::component();
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let world = World::open(temp.path()).unwrap();
|
||||
std::fs::create_dir_all(temp.path().join("world/bin")).unwrap();
|
||||
std::fs::copy(guest, temp.path().join("world/bin/guest.wasm")).unwrap();
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let port = listener.local_addr().unwrap().port();
|
||||
let c = Arc::new(cred::Cred::open(&world.host_dir).unwrap());
|
||||
let caller = cred::Caller::default();
|
||||
let item = c
|
||||
.create_item(
|
||||
Item {
|
||||
id: String::new(),
|
||||
name: "test item".into(),
|
||||
version: 0,
|
||||
free_use: true,
|
||||
rules: vec![Rule {
|
||||
port: Some(port),
|
||||
scheme: "http".into(),
|
||||
hosts: vec!["127.0.0.1".into()],
|
||||
path_prefixes: vec!["/free".into()],
|
||||
methods: None,
|
||||
}],
|
||||
validate_js: None,
|
||||
transform_js: "inject".into(),
|
||||
fields: vec![Field {
|
||||
field_id: String::new(),
|
||||
name: "token".into(),
|
||||
public: false,
|
||||
value: None,
|
||||
needs_value: true,
|
||||
field_type: FieldType::Text,
|
||||
key_type: None,
|
||||
}],
|
||||
},
|
||||
&caller,
|
||||
c.unlocked().unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
let token = uuid::Uuid::new_v4().to_string();
|
||||
c.set_field(
|
||||
&item.id,
|
||||
&item.fields[0].field_id,
|
||||
&token,
|
||||
&caller,
|
||||
c.unlocked().unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
let runtime = Runtime::new_with_hooks(world, c.clone(), Arc::new(Fake), None).unwrap();
|
||||
let identity = run(
|
||||
&runtime,
|
||||
"http://host.sausage/whoami".into(),
|
||||
vec!["child".into(), "root".into()],
|
||||
)
|
||||
.await;
|
||||
let who: serde_json::Value =
|
||||
serde_json::from_str(identity.lines().find(|line| line.starts_with('{')).unwrap()).unwrap();
|
||||
assert_eq!(who["pid"], 1);
|
||||
assert_eq!(who["program"], "/bin/guest.wasm");
|
||||
assert_eq!(who["conversations"], serde_json::json!(["child", "root"]));
|
||||
std::os::unix::fs::symlink("guest.wasm", temp.path().join("world/bin/alias")).unwrap();
|
||||
let mut alias_out = Vec::new();
|
||||
let code = runtime
|
||||
.exec(
|
||||
Command::Argv(vec![
|
||||
"/bin/alias".into(),
|
||||
"custom://host.sausage/whoami".into(),
|
||||
"GET".into(),
|
||||
]),
|
||||
Options {
|
||||
cwd: "/".into(),
|
||||
..Default::default()
|
||||
},
|
||||
&mut |_, b| alias_out.extend_from_slice(b),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(code, 0);
|
||||
let alias_out = String::from_utf8(alias_out).unwrap();
|
||||
assert!(alias_out.contains("/bin/guest.wasm") && !alias_out.contains("/bin/alias"));
|
||||
let items = run(&runtime, "http://host.sausage/cred/items".into(), vec![]).await;
|
||||
assert!(items.contains("test item") && !items.contains(&token));
|
||||
let server_token = token.clone();
|
||||
let server = tokio::spawn(async move {
|
||||
let (mut socket, _) = listener.accept().await.unwrap();
|
||||
let mut bytes = Vec::new();
|
||||
loop {
|
||||
let mut b = [0; 1024];
|
||||
let n = socket.read(&mut b).await.unwrap();
|
||||
assert!(n > 0);
|
||||
bytes.extend_from_slice(&b[..n]);
|
||||
if bytes.ends_with(b"0\r\n\r\n")
|
||||
|| (bytes.ends_with(b"\r\n\r\n")
|
||||
&& !String::from_utf8_lossy(&bytes).contains("transfer-encoding: chunked"))
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
let text = String::from_utf8(bytes).unwrap();
|
||||
assert!(
|
||||
text.contains(&server_token),
|
||||
"server did not receive the credential"
|
||||
);
|
||||
socket
|
||||
.write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\nok")
|
||||
.await
|
||||
.unwrap();
|
||||
});
|
||||
let url = format!("http://127.0.0.1:{port}/free");
|
||||
let out = run(&runtime, url.clone(), vec!["root".into()]).await;
|
||||
assert!(out.contains("status=200") && !out.contains(&token));
|
||||
server.await.unwrap();
|
||||
let (public_url, public) = upstream(None, 0, 200, 0, None).await;
|
||||
let location = format!("{}/public", public_url.replace("127.0.0.1", "localhost"));
|
||||
let (redirect_url, redirect) =
|
||||
upstream(Some(token.clone()), 0, 302, port, Some(location)).await;
|
||||
let mut follow_out = Vec::new();
|
||||
let code = runtime
|
||||
.exec(
|
||||
Command::Argv(vec![
|
||||
"/bin/guest.wasm".into(),
|
||||
"follow".into(),
|
||||
format!("{redirect_url}/free"),
|
||||
]),
|
||||
Options {
|
||||
cwd: "/".into(),
|
||||
timeout: Some(std::time::Duration::from_secs(30)),
|
||||
..Default::default()
|
||||
},
|
||||
&mut |_, b| follow_out.extend_from_slice(b),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(code, 0);
|
||||
let follow_out = String::from_utf8(follow_out).unwrap();
|
||||
assert!(
|
||||
follow_out.contains("first-status=302")
|
||||
&& follow_out.contains("status=200")
|
||||
&& !follow_out.contains(&token)
|
||||
);
|
||||
redirect.await.unwrap();
|
||||
public.await.unwrap();
|
||||
let mut changed = item.clone();
|
||||
changed.free_use = false;
|
||||
// A second matching item must fail before any network request.
|
||||
changed.id = String::new();
|
||||
changed.name = "second".into();
|
||||
changed.rules[0].path_prefixes = vec!["/free".into(), "/deny".into()];
|
||||
let second = c
|
||||
.create_item(changed, &caller, c.unlocked().unwrap())
|
||||
.unwrap();
|
||||
assert!(
|
||||
run(&runtime, url.clone(), vec![])
|
||||
.await
|
||||
.contains("ambiguous_item")
|
||||
);
|
||||
let url = format!("http://127.0.0.1:{port}/deny");
|
||||
let out = run(&runtime, url.clone(), vec!["other".into()]).await;
|
||||
assert!(out.contains("status=403") && out.contains("second") && out.contains("request"));
|
||||
let again = run(&runtime, url, vec!["other".into()]).await;
|
||||
assert!(again.contains("status=403"));
|
||||
assert_eq!(c.request_list().unwrap().len(), 1);
|
||||
c.set_field(
|
||||
&second.id,
|
||||
&second.fields[0].field_id,
|
||||
&token,
|
||||
&caller,
|
||||
c.unlocked().unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
c.approve(
|
||||
&c.request_list().unwrap()[0].id,
|
||||
cred::webauthn::Decision::Approve,
|
||||
cred::requests::Overrides::default(),
|
||||
c.unlocked().unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
let (url, task) = upstream(Some(token.clone()), 0, 200, port, None).await;
|
||||
let out = run(
|
||||
&runtime,
|
||||
format!("{url}/deny"),
|
||||
vec!["descendant".into(), "other".into()],
|
||||
)
|
||||
.await;
|
||||
assert!(out.contains("status=200") && !out.contains(&token));
|
||||
task.await.unwrap();
|
||||
let (child_url, task) = upstream(Some(token.clone()), 0, 200, port, None).await;
|
||||
let mut child_out = Vec::new();
|
||||
let code = runtime
|
||||
.exec(
|
||||
Command::Argv(vec![
|
||||
"/bin/guest.wasm".into(),
|
||||
"spawn".into(),
|
||||
format!("{child_url}/deny"),
|
||||
]),
|
||||
Options {
|
||||
cwd: "/".into(),
|
||||
conversations: vec!["descendant".into(), "other".into()],
|
||||
timeout: Some(std::time::Duration::from_secs(30)),
|
||||
..Default::default()
|
||||
},
|
||||
&mut |_, b| child_out.extend_from_slice(b),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(code, 0);
|
||||
let child_out = String::from_utf8(child_out).unwrap();
|
||||
assert!(child_out.contains("status=200") && !child_out.contains(&token));
|
||||
task.await.unwrap();
|
||||
let denied = run(&runtime, format!("{url}/deny"), vec!["sibling".into()]).await;
|
||||
assert!(denied.contains("status=403"));
|
||||
}
|
||||
|
||||
async fn upstream(
|
||||
token: Option<String>,
|
||||
length: usize,
|
||||
status: u16,
|
||||
port: u16,
|
||||
location: Option<String>,
|
||||
) -> (String, tokio::task::JoinHandle<()>) {
|
||||
use http_body_util::{BodyExt, Full};
|
||||
let listener = tokio::net::TcpListener::bind((std::net::Ipv4Addr::LOCALHOST, port))
|
||||
.await
|
||||
.unwrap();
|
||||
let url = format!("http://{}", listener.local_addr().unwrap());
|
||||
let task = tokio::spawn(async move {
|
||||
let (socket, _) = listener.accept().await.unwrap();
|
||||
let service =
|
||||
hyper::service::service_fn(move |request: http::Request<hyper::body::Incoming>| {
|
||||
let token = token.clone();
|
||||
let location = location.clone();
|
||||
async move {
|
||||
match token {
|
||||
Some(token) => assert!(
|
||||
request
|
||||
.headers()
|
||||
.get("authorization")
|
||||
.is_some_and(|h| h.as_bytes() == token.as_bytes()),
|
||||
"credential absent or incorrect"
|
||||
),
|
||||
None => assert!(!request.headers().contains_key("authorization")),
|
||||
}
|
||||
let mut body = request.into_body();
|
||||
let mut received = 0;
|
||||
while let Some(frame) = body.frame().await {
|
||||
if let Ok(data) = frame.unwrap().into_data() {
|
||||
received += data.len();
|
||||
}
|
||||
}
|
||||
assert_eq!(received, length);
|
||||
Ok::<_, std::convert::Infallible>(
|
||||
http::Response::builder()
|
||||
.status(status)
|
||||
.header(
|
||||
"location",
|
||||
location.as_deref().unwrap_or("http://localhost/public"),
|
||||
)
|
||||
.body(Full::new(bytes::Bytes::from_static(b"ok")))
|
||||
.unwrap(),
|
||||
)
|
||||
}
|
||||
});
|
||||
hyper::server::conn::http1::Builder::new()
|
||||
.keep_alive(false)
|
||||
.serve_connection(hyper_util::rt::TokioIo::new(socket), service)
|
||||
.await
|
||||
.unwrap();
|
||||
});
|
||||
(url, task)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn streamed_upload_redirect_and_http_admin_denial() {
|
||||
let guest = hook_guest::component();
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let world = World::open(temp.path()).unwrap();
|
||||
std::fs::create_dir_all(temp.path().join("world/bin")).unwrap();
|
||||
std::fs::copy(guest, temp.path().join("world/bin/guest.wasm")).unwrap();
|
||||
let runtime = Runtime::new(world).unwrap();
|
||||
let (url, task) = upstream(None, 4 * 1024 * 1024, 302, 0, None).await;
|
||||
let mut out = Vec::new();
|
||||
let code = runtime
|
||||
.exec(
|
||||
Command::Argv(vec![
|
||||
"/bin/guest.wasm".into(),
|
||||
url,
|
||||
"POST".into(),
|
||||
(4 * 1024 * 1024).to_string(),
|
||||
]),
|
||||
Options {
|
||||
cwd: "/".into(),
|
||||
timeout: Some(std::time::Duration::from_secs(30)),
|
||||
..Default::default()
|
||||
},
|
||||
&mut |_, b| out.extend_from_slice(b),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(code, 0);
|
||||
assert!(String::from_utf8(out).unwrap().contains("status=302"));
|
||||
task.await.unwrap();
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
runtime
|
||||
.network
|
||||
.deny_host_port(listener.local_addr().unwrap().port());
|
||||
assert!(
|
||||
run(
|
||||
&runtime,
|
||||
format!("http://{}/", listener.local_addr().unwrap()),
|
||||
vec![]
|
||||
)
|
||||
.await
|
||||
.contains("error")
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
//! The hook and native listener share ceremony state without exposing the native listener to guests.
|
||||
use sausage::{
|
||||
api::{Api, Listener, Options as ApiOptions},
|
||||
cred::{Caller, Cred},
|
||||
exec::{Command, Exec, Options},
|
||||
process::Runtime,
|
||||
world::World,
|
||||
};
|
||||
use std::{
|
||||
io::{Read, Write},
|
||||
sync::Arc,
|
||||
};
|
||||
#[path = "hook_guest.rs"]
|
||||
mod hook_guest;
|
||||
|
||||
#[tokio::test]
|
||||
async fn one_api_for_pages_and_guest_requests() -> anyhow::Result<()> {
|
||||
use webauthn_authenticator_rs::{WebauthnAuthenticator, softpasskey::SoftPasskey};
|
||||
let dir = tempfile::tempdir()?;
|
||||
let world = World::open(dir.path())?;
|
||||
std::fs::create_dir_all(dir.path().join("world/bin"))?;
|
||||
std::fs::copy(
|
||||
hook_guest::component(),
|
||||
dir.path().join("world/bin/guest.wasm"),
|
||||
)?;
|
||||
let cred = Arc::new(Cred::open(&world.host_dir)?);
|
||||
let origin = "http://localhost:8081";
|
||||
let api = Arc::new(Api::open(
|
||||
cred.clone(),
|
||||
&world.host_dir,
|
||||
"localhost",
|
||||
origin,
|
||||
)?);
|
||||
let runtime = Runtime::new_with_hooks(
|
||||
world,
|
||||
cred,
|
||||
sausage::process::default_script(),
|
||||
Some(api.clone()),
|
||||
)?;
|
||||
let listener = Listener::start_with_api(
|
||||
api.clone(),
|
||||
ApiOptions {
|
||||
api_listen: "127.0.0.1:0".parse()?,
|
||||
api_rp_id: "localhost".into(),
|
||||
api_origin: origin.into(),
|
||||
},
|
||||
)?;
|
||||
runtime
|
||||
.network_policy()
|
||||
.deny_host_port(listener.address().port());
|
||||
let response = sausage::api::handle(
|
||||
&api,
|
||||
Caller {
|
||||
program: "/bin/guest.wasm".into(),
|
||||
pid: 1,
|
||||
conversations: vec![],
|
||||
},
|
||||
http::Request::post("http://host.sausage/cred/passkeys/register/start")
|
||||
.body(bytes::Bytes::from_static(b"{\"label\":\"shared\"}"))?,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(response.status(), 200);
|
||||
let challenge: serde_json::Value = serde_json::from_slice(response.body())?;
|
||||
let mut authenticator = WebauthnAuthenticator::new(SoftPasskey::new(true));
|
||||
let registration = authenticator.do_registration(
|
||||
url::Url::parse(origin)?,
|
||||
serde_json::from_value(challenge["options"].clone())?,
|
||||
)?;
|
||||
let body = serde_json::to_vec(
|
||||
&serde_json::json!({"challenge_id":challenge["id"], "response": registration}),
|
||||
)?;
|
||||
let mut connection = std::net::TcpStream::connect(listener.address())?;
|
||||
write!(
|
||||
connection,
|
||||
"POST /cred/passkeys/register/finish HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\nContent-Length: {}\r\n\r\n",
|
||||
body.len()
|
||||
)?;
|
||||
connection.write_all(&body)?;
|
||||
let mut reply = String::new();
|
||||
connection.read_to_string(&mut reply)?;
|
||||
assert!(reply.starts_with("HTTP/1.1 200"));
|
||||
for (uri, expected) in [
|
||||
("http://host.sausage/cred/passkeys".to_owned(), "shared"),
|
||||
(format!("http://{}/whoami", listener.address()), "error"),
|
||||
] {
|
||||
let mut out = Vec::new();
|
||||
let code = runtime
|
||||
.exec(
|
||||
Command::Argv(vec!["/bin/guest.wasm".into(), uri, "GET".into()]),
|
||||
Options {
|
||||
cwd: "/".into(),
|
||||
timeout: Some(std::time::Duration::from_secs(30)),
|
||||
..Default::default()
|
||||
},
|
||||
&mut |_, b| out.extend_from_slice(b),
|
||||
)
|
||||
.await?;
|
||||
assert_eq!(code, 0);
|
||||
assert!(String::from_utf8(out)?.contains(expected));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
//! A real git component pushes an incompressible commit through the hook to native git's HTTP backend.
|
||||
use sausage::{
|
||||
exec::{Command, Exec, Options},
|
||||
process::Runtime,
|
||||
world::World,
|
||||
};
|
||||
use std::{
|
||||
io::Write,
|
||||
path::Path,
|
||||
process::Stdio,
|
||||
sync::{Arc, OnceLock},
|
||||
};
|
||||
fn git(cwd: &Path, args: &[&str]) {
|
||||
assert!(
|
||||
std::process::Command::new("git")
|
||||
.args(args)
|
||||
.current_dir(cwd)
|
||||
.status()
|
||||
.unwrap()
|
||||
.success()
|
||||
);
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn several_mb_git_push() {
|
||||
static BUILT: OnceLock<std::path::PathBuf> = OnceLock::new();
|
||||
let component = BUILT.get_or_init(|| {
|
||||
let target = Path::new(env!("CARGO_TARGET_TMPDIR")).join("hook-git");
|
||||
let userland = Path::new(env!("CARGO_MANIFEST_DIR")).join("../userland");
|
||||
if !userland.join("cwd-init/init.o").is_file() {
|
||||
assert!(
|
||||
std::process::Command::new("nu")
|
||||
.args(["--no-config-file", "cwd-init/build.nu"])
|
||||
.current_dir(&userland)
|
||||
.status()
|
||||
.unwrap()
|
||||
.success()
|
||||
);
|
||||
}
|
||||
let manifest = userland.join("Cargo.toml");
|
||||
assert!(
|
||||
std::process::Command::new("cargo")
|
||||
.args(["build", "--release", "-p", "git", "--manifest-path"])
|
||||
.arg(manifest)
|
||||
.current_dir(&userland)
|
||||
.args(["--target", "wasm32-wasip2", "--target-dir"])
|
||||
.arg(&target)
|
||||
.args(["-j", "2", "--locked"])
|
||||
.status()
|
||||
.unwrap()
|
||||
.success()
|
||||
);
|
||||
target.join("wasm32-wasip2/release/git.wasm")
|
||||
});
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let world = World::open(temp.path()).unwrap();
|
||||
let root = temp.path().join("world");
|
||||
std::fs::create_dir_all(root.join("bin")).unwrap();
|
||||
std::fs::copy(component, root.join("bin/git.wasm")).unwrap();
|
||||
let repo = root.join("repo");
|
||||
std::fs::create_dir_all(&repo).unwrap();
|
||||
git(&repo, &["init", "-b", "main"]);
|
||||
let mut data = vec![0; 4 * 1024 * 1024];
|
||||
openssl::rand::rand_bytes(&mut data).unwrap();
|
||||
std::fs::write(repo.join("large"), data).unwrap();
|
||||
git(&repo, &["add", "large"]);
|
||||
git(
|
||||
&repo,
|
||||
&[
|
||||
"-c",
|
||||
"user.name=Test",
|
||||
"-c",
|
||||
"user.email=test@example.com",
|
||||
"commit",
|
||||
"-m",
|
||||
"large commit",
|
||||
],
|
||||
);
|
||||
let remote = temp.path().join("remote");
|
||||
std::fs::create_dir_all(&remote).unwrap();
|
||||
git(&remote, &["init", "--bare", "destination.git"]);
|
||||
git(
|
||||
&remote.join("destination.git"),
|
||||
&["config", "http.receivepack", "true"],
|
||||
);
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let port = listener.local_addr().unwrap().port();
|
||||
git(
|
||||
&repo,
|
||||
&[
|
||||
"remote",
|
||||
"add",
|
||||
"origin",
|
||||
&format!("http://127.0.0.1:{port}/destination.git"),
|
||||
],
|
||||
);
|
||||
let uploaded = Arc::new(std::sync::atomic::AtomicUsize::new(0));
|
||||
let counter = uploaded.clone();
|
||||
let server = tokio::spawn(async move {
|
||||
loop {
|
||||
let (socket, _) = listener.accept().await.unwrap();
|
||||
let remote = remote.clone();
|
||||
let counter = counter.clone();
|
||||
let service =
|
||||
hyper::service::service_fn(move |request: http::Request<hyper::body::Incoming>| {
|
||||
let remote = remote.clone();
|
||||
let counter = counter.clone();
|
||||
async move {
|
||||
use http_body_util::{BodyExt, Full};
|
||||
let (parts, body) = request.into_parts();
|
||||
let body = body.collect().await.unwrap().to_bytes();
|
||||
counter.fetch_add(body.len(), std::sync::atomic::Ordering::Relaxed);
|
||||
let response = tokio::task::spawn_blocking(move || {
|
||||
let mut process = std::process::Command::new("git")
|
||||
.arg("http-backend")
|
||||
.env("GIT_PROJECT_ROOT", remote)
|
||||
.env("GIT_HTTP_EXPORT_ALL", "1")
|
||||
.env("REQUEST_METHOD", parts.method.as_str())
|
||||
.env("PATH_INFO", parts.uri.path())
|
||||
.env("QUERY_STRING", parts.uri.query().unwrap_or(""))
|
||||
.env(
|
||||
"CONTENT_TYPE",
|
||||
parts
|
||||
.headers
|
||||
.get("content-type")
|
||||
.map(|h| h.to_str().unwrap())
|
||||
.unwrap_or(""),
|
||||
)
|
||||
.env("CONTENT_LENGTH", body.len().to_string())
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.spawn()
|
||||
.unwrap();
|
||||
let mut stdin = process.stdin.take().unwrap();
|
||||
let output = std::thread::scope(|scope| {
|
||||
scope.spawn(move || {
|
||||
stdin.write_all(&body).unwrap();
|
||||
});
|
||||
process.wait_with_output().unwrap()
|
||||
});
|
||||
assert!(output.status.success());
|
||||
let split = output
|
||||
.stdout
|
||||
.windows(4)
|
||||
.position(|b| b == b"\r\n\r\n")
|
||||
.unwrap();
|
||||
let headers = std::str::from_utf8(&output.stdout[..split]).unwrap();
|
||||
let mut response = http::Response::builder();
|
||||
for line in headers.split("\r\n") {
|
||||
let (name, value) = line.split_once(':').unwrap();
|
||||
if name.eq_ignore_ascii_case("status") {
|
||||
response = response.status(
|
||||
value
|
||||
.trim()
|
||||
.split(' ')
|
||||
.next()
|
||||
.unwrap()
|
||||
.parse::<u16>()
|
||||
.unwrap(),
|
||||
);
|
||||
} else {
|
||||
response = response.header(name, value.trim());
|
||||
}
|
||||
}
|
||||
response
|
||||
.body(Full::new(bytes::Bytes::copy_from_slice(
|
||||
&output.stdout[split + 4..],
|
||||
)))
|
||||
.unwrap()
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
Ok::<_, std::convert::Infallible>(response)
|
||||
}
|
||||
});
|
||||
hyper::server::conn::http1::Builder::new()
|
||||
.keep_alive(false)
|
||||
.serve_connection(hyper_util::rt::TokioIo::new(socket), service)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
});
|
||||
let runtime = Runtime::new(world).unwrap();
|
||||
let mut output = Vec::new();
|
||||
let code = runtime
|
||||
.exec(
|
||||
Command::Argv(vec![
|
||||
"/bin/git.wasm".into(),
|
||||
"push".into(),
|
||||
"origin".into(),
|
||||
"main".into(),
|
||||
]),
|
||||
Options {
|
||||
cwd: "/repo".into(),
|
||||
timeout: Some(std::time::Duration::from_secs(60)),
|
||||
..Default::default()
|
||||
},
|
||||
&mut |_, b| output.extend_from_slice(b),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
server.abort();
|
||||
assert_eq!(code, 0, "{}", String::from_utf8_lossy(&output));
|
||||
assert!(uploaded.load(std::sync::atomic::Ordering::Relaxed) > 3 * 1024 * 1024);
|
||||
git(
|
||||
&temp.path().join("remote/destination.git"),
|
||||
&["cat-file", "-e", "refs/heads/main:large"],
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
//! Persistent test component shared by HTTP and socket acceptance tests.
|
||||
use std::{
|
||||
path::{Path, PathBuf},
|
||||
sync::OnceLock,
|
||||
};
|
||||
fn write(path: PathBuf, text: &str) -> std::io::Result<()> {
|
||||
if std::fs::read_to_string(&path).ok().as_deref() != Some(text) {
|
||||
std::fs::write(path, text)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
pub fn component() -> &'static Path {
|
||||
static PATH: OnceLock<PathBuf> = OnceLock::new();
|
||||
PATH.get_or_init(|| {
|
||||
let directory = Path::new(env!("CARGO_TARGET_TMPDIR")).join("hook-guest");
|
||||
std::fs::create_dir_all(directory.join("src")).unwrap();
|
||||
std::fs::create_dir_all(directory.join("wit/deps")).unwrap();
|
||||
write(directory.join("wit/process.wit"), include_str!("../wit/process.wit")).unwrap();
|
||||
write(directory.join("wit/deps/io.wit"), include_str!("../wit/deps/io.wit")).unwrap();
|
||||
write(directory.join("Cargo.toml"), "[package]\nname='hook-guest'\nversion='0.1.0'\nedition='2024'\n[workspace]\n[dependencies]\nwstd='=0.6.8'\nanyhow='1'\nfutures-lite='2'\nwit-bindgen='0.46'\n").unwrap();
|
||||
write(directory.join("src/main.rs"), r#"
|
||||
use wstd::http::{Body, BodyExt, Client, Request};
|
||||
mod bindings { wit_bindgen::generate!({path:"wit",world:"program",generate_all}); }
|
||||
#[wstd::main]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
let args: Vec<_> = std::env::args().collect();
|
||||
match args[1].as_str() {
|
||||
"follow" => {
|
||||
let response=Client::new().send(Request::get(&args[2]).body(Body::empty())?).await?;
|
||||
println!("first-status={}",response.status().as_u16());
|
||||
let location=response.headers()["location"].to_str()?.to_owned();
|
||||
response.into_body().into_boxed_body().collect().await?;
|
||||
let response=Client::new().send(Request::get(location).body(Body::empty())?).await?;
|
||||
println!("status={}",response.status().as_u16());
|
||||
response.into_body().into_boxed_body().collect().await?;
|
||||
},
|
||||
"spawn" => {
|
||||
use bindings::sausage::process::host::{self, Request, Stdio};
|
||||
let child=host::spawn(&Request {program:"/bin/guest.wasm".into(),argv:vec!["guest".into(),args[2].clone(),"GET".into()],cwd:"/".into(),env:vec![],group:None,stdin:Stdio::Null,stdout:Stdio::Inherit,stderr:Stdio::Inherit}).unwrap();
|
||||
println!("child={}",child.pid());
|
||||
assert_eq!(child.wait(),0);
|
||||
},
|
||||
"listen" => {
|
||||
let listener = std::net::TcpListener::bind(&args[2])?;
|
||||
println!("ready");
|
||||
let (mut stream, _) = listener.accept()?;
|
||||
use std::io::{Read, Write};
|
||||
let mut bytes = [0;4]; stream.read_exact(&mut bytes)?;
|
||||
stream.write_all(&bytes)?;
|
||||
},
|
||||
"connect" => {
|
||||
use std::io::{Read, Write};
|
||||
match std::net::TcpStream::connect(&args[2]) {
|
||||
Ok(mut stream) => { stream.write_all(b"ping")?; let mut bytes=[0;4]; stream.read_exact(&mut bytes)?; println!("{}", std::str::from_utf8(&bytes)?); },
|
||||
Err(_) => println!("denied"),
|
||||
}
|
||||
},
|
||||
_ => {
|
||||
let body = if args.len() > 3 { Body::from_stream(futures_lite::stream::iter((0..args[3].parse::<usize>()?/8192).map(|_| vec![b'x';8192]))) } else { Body::empty() };
|
||||
let request = Request::builder().uri(&args[1]).method(args[2].as_str()).body(body)?;
|
||||
match Client::new().send(request).await {
|
||||
Ok(response) => { println!("status={}", response.status().as_u16()); let bytes=response.into_body().into_boxed_body().collect().await?.to_bytes(); println!("{}",String::from_utf8_lossy(&bytes)); },
|
||||
Err(_) => println!("error"),
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
"#).unwrap();
|
||||
assert!(std::process::Command::new("cargo").args(["build", "--manifest-path"]).arg(directory.join("Cargo.toml")).args(["--target", "wasm32-wasip2", "--release", "-j", "2"]).env("CARGO_TARGET_DIR", directory.join("target")).status().unwrap().success(), "test guest build failed");
|
||||
directory.join("target/wasm32-wasip2/release/hook-guest.wasm")
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
//! Real components can accept TCP connections. Host port registration and host-only config deny access.
|
||||
mod hook_guest;
|
||||
use sausage::{
|
||||
exec::{Command, Exec, Options},
|
||||
process::{Runtime, net::NetPolicy},
|
||||
world::World,
|
||||
};
|
||||
use std::{sync::Arc, time::Duration};
|
||||
#[test]
|
||||
fn host_policy() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
std::fs::write(
|
||||
temp.path().join("network.toml"),
|
||||
"deny = ['127.0.0.1:9001', '[::1]:9002']",
|
||||
)
|
||||
.unwrap();
|
||||
let policy = NetPolicy::open(temp.path()).unwrap();
|
||||
policy.deny_host_port(9000);
|
||||
for address in [
|
||||
"127.0.0.1:9000",
|
||||
"0.0.0.0:9000",
|
||||
"[::1]:9000",
|
||||
"[::ffff:127.0.0.1]:9000",
|
||||
"127.0.0.1:9001",
|
||||
"[::1]:9002",
|
||||
] {
|
||||
assert!(!policy.allows(address.parse().unwrap()));
|
||||
}
|
||||
assert!(policy.allows("0.0.0.0:8080".parse().unwrap()));
|
||||
for interface in if_addrs::get_if_addrs().unwrap() {
|
||||
assert!(!policy.allows(std::net::SocketAddr::new(interface.ip(), 9000)));
|
||||
}
|
||||
assert!(!policy.allows("[::ffff:127.0.0.1]:9001".parse().unwrap()));
|
||||
}
|
||||
async fn run(runtime: &Arc<Runtime>, mode: &str, address: String) -> String {
|
||||
let mut output = Vec::new();
|
||||
let code = runtime
|
||||
.exec(
|
||||
Command::Argv(vec!["/bin/guest.wasm".into(), mode.into(), address]),
|
||||
Options {
|
||||
cwd: "/".into(),
|
||||
timeout: Some(Duration::from_secs(30)),
|
||||
..Default::default()
|
||||
},
|
||||
&mut |_, bytes| output.extend_from_slice(bytes),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(code, 0);
|
||||
String::from_utf8(output).unwrap()
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn guest_tcp_and_host_denial() {
|
||||
let guest = hook_guest::component();
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let world = World::open(temp.path()).unwrap();
|
||||
std::fs::create_dir_all(temp.path().join("world/bin")).unwrap();
|
||||
std::fs::copy(guest, temp.path().join("world/bin/guest.wasm")).unwrap();
|
||||
let configured = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
std::fs::write(
|
||||
world.host_dir.join("network.toml"),
|
||||
format!("deny = ['{}']", configured.local_addr().unwrap()),
|
||||
)
|
||||
.unwrap();
|
||||
let runtime = Runtime::new(world).unwrap();
|
||||
assert!(
|
||||
run(
|
||||
&runtime,
|
||||
"connect",
|
||||
configured.local_addr().unwrap().to_string()
|
||||
)
|
||||
.await
|
||||
.contains("denied")
|
||||
);
|
||||
let native = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let denied = native.local_addr().unwrap();
|
||||
runtime.network.deny_host_port(denied.port());
|
||||
assert!(
|
||||
run(&runtime, "connect", denied.to_string())
|
||||
.await
|
||||
.contains("denied")
|
||||
);
|
||||
let ephemeral = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let address = ephemeral.local_addr().unwrap();
|
||||
drop(ephemeral);
|
||||
let server_runtime = runtime.clone();
|
||||
let (tx, rx) = tokio::sync::oneshot::channel();
|
||||
let server = tokio::spawn(async move {
|
||||
let mut tx = Some(tx);
|
||||
let mut bytes = Vec::new();
|
||||
server_runtime
|
||||
.exec(
|
||||
Command::Argv(vec![
|
||||
"/bin/guest.wasm".into(),
|
||||
"listen".into(),
|
||||
format!("0.0.0.0:{}", address.port()),
|
||||
]),
|
||||
Options {
|
||||
cwd: "/".into(),
|
||||
timeout: Some(Duration::from_secs(30)),
|
||||
..Default::default()
|
||||
},
|
||||
&mut |_, chunk| {
|
||||
bytes.extend_from_slice(chunk);
|
||||
if bytes.windows(5).any(|w| w == b"ready")
|
||||
&& let Some(tx) = tx.take()
|
||||
{
|
||||
let _ = tx.send(());
|
||||
}
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
});
|
||||
tokio::time::timeout(Duration::from_secs(30), rx)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert!(
|
||||
run(&runtime, "connect", format!("localhost:{}", address.port()))
|
||||
.await
|
||||
.contains("ping")
|
||||
);
|
||||
assert_eq!(server.await.unwrap(), 0);
|
||||
}
|
||||
@@ -264,7 +264,7 @@ mod features {
|
||||
exec.exec(
|
||||
Command::Shell("sleep 60 | cat".into()),
|
||||
Options {
|
||||
conversation: Some("c1".into()),
|
||||
conversations: vec!["c1".into()],
|
||||
..Default::default()
|
||||
},
|
||||
&mut |_, _| {},
|
||||
|
||||
Reference in New Issue
Block a user