diff --git a/CHANGELOG.md b/CHANGELOG.md index 65a8506..b50fe85 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,10 @@ Changelog tracking starts with 0.2.0. Prior versions were not tracked. ## [Unreleased] +### Added + +- **`astrid_sdk::net::connect(host, port)` + `astrid_sdk::net::TcpStream`** — outbound TCP. `connect` is the low-level call returning a `StreamHandle`, parallel to the existing `accept`. `TcpStream` is the `std::net::TcpStream`-shaped facade with `std::io::Read + Write` impls and RAII close-on-drop; user code generally writes `TcpStream::connect("host:port")`. Underlying ABI is the new `astrid:capsule/net.net-connect-tcp` host fn ([wit#5](https://github.com/unicity-astrid/wit/pull/5)); capability-gated against a per-capsule `net_connect` allowlist in `Capsule.toml` (kernel-side, separate PR). SSRF airlock runs on the resolved IP, matching the gate on `http-request`. Unblocks WebSocket, MQTT, Discord/Telegram, postgres/redis, etc. Tracking issue: [astrid#745](https://github.com/unicity-astrid/astrid/issues/745). RFC: [rfcs#27](https://github.com/unicity-astrid/rfcs/pull/27). + ## [0.6.1] - 2026-05-19 ### Added diff --git a/astrid-sdk/src/net.rs b/astrid-sdk/src/net.rs index b2d8e5e..2cf6771 100644 --- a/astrid-sdk/src/net.rs +++ b/astrid-sdk/src/net.rs @@ -153,3 +153,430 @@ pub fn send(stream: &StreamHandle, data: &[u8]) -> Result<(), SendError> { pub fn close(stream: &StreamHandle) -> Result<(), SysError> { wit_net::net_close_stream(stream.0).map_err(SysError::HostError) } + +// --------------------------------------------------------------------------- +// Outbound TCP — std::net::TcpStream parity +// --------------------------------------------------------------------------- + +/// Direction argument for [`TcpStream::shutdown`] — mirror of +/// [`std::net::Shutdown`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Shutdown { + /// Half-close the read side. + Read, + /// Half-close the write side. + Write, + /// Close both directions. + Both, +} + +/// Open an outbound TCP connection to `host:port` and return a stream handle. +/// +/// The capsule's `Capsule.toml` must declare a `net_connect` allowlist entry +/// matching `host:port` (exact `"host:port"` or `"host:*"`); missing or empty +/// allowlist denies all outbound TCP. The kernel runs the same SSRF airlock +/// used by `http-request` on the resolved IP and enforces a connect timeout +/// (10s default). +/// +/// The returned handle flows through the byte-stream surface +/// ([`read_bytes`] / [`write_bytes`] / [`close`]) and the std-shaped +/// [`TcpStream`] facade. The frame-oriented [`recv`] / [`send`] also work +/// but are intended for the inbound Unix-accept proxy use case. +pub fn connect(host: &str, port: u16) -> Result { + let handle = wit_net::net_connect_tcp(host, port).map_err(SysError::HostError)?; + Ok(StreamHandle(handle)) +} + +/// Read up to `max_bytes` from `stream` without length-prefix framing. +/// +/// Mirrors `std::io::Read::read`. Empty result means EOF (peer +/// disconnected). Honours any read timeout previously set via +/// [`set_read_timeout`]. +pub fn read_bytes(stream: &StreamHandle, max_bytes: u32) -> Result, SysError> { + wit_net::net_read_bytes(stream.0, max_bytes).map_err(SysError::HostError) +} + +/// Write `data` to `stream` without framing. Returns the number of bytes +/// actually written (may be less than `data.len()`). Honours any write +/// timeout previously set via [`set_write_timeout`]. +pub fn write_bytes(stream: &StreamHandle, data: &[u8]) -> Result { + wit_net::net_write_bytes(stream.0, data).map_err(SysError::HostError) +} + +/// Peek up to `max_bytes` without consuming them — the next +/// [`read_bytes`] returns the same data again. +pub fn peek(stream: &StreamHandle, max_bytes: u32) -> Result, SysError> { + wit_net::net_peek(stream.0, max_bytes).map_err(SysError::HostError) +} + +/// Half-close the read side, write side, or both. +pub fn shutdown(stream: &StreamHandle, how: Shutdown) -> Result<(), SysError> { + let wit_how = match how { + Shutdown::Read => wit_types::ShutdownHow::Read, + Shutdown::Write => wit_types::ShutdownHow::Write, + Shutdown::Both => wit_types::ShutdownHow::Both, + }; + wit_net::net_shutdown(stream.0, wit_how).map_err(SysError::HostError) +} + +/// Remote peer address, formatted as `"ip:port"`. +pub fn peer_addr(stream: &StreamHandle) -> Result { + wit_net::net_peer_addr(stream.0).map_err(SysError::HostError) +} + +/// Local socket address, formatted as `"ip:port"`. +pub fn local_addr(stream: &StreamHandle) -> Result { + wit_net::net_local_addr(stream.0).map_err(SysError::HostError) +} + +/// Toggle `TCP_NODELAY` (Nagle off when `true`). +pub fn set_nodelay(stream: &StreamHandle, nodelay: bool) -> Result<(), SysError> { + wit_net::net_set_nodelay(stream.0, nodelay).map_err(SysError::HostError) +} + +/// Current `TCP_NODELAY` setting. +pub fn nodelay(stream: &StreamHandle) -> Result { + wit_net::net_nodelay(stream.0).map_err(SysError::HostError) +} + +/// Validate + convert a timeout to milliseconds for the host fn. +/// +/// Mirrors `std::net::TcpStream::set_read_timeout` / +/// `set_write_timeout`, which both reject `Some(Duration::ZERO)` — +/// zero would be ambiguous with "no timeout". +fn to_host_timeout(timeout: Option) -> Result, SysError> { + match timeout { + Some(d) if d.is_zero() => Err(SysError::HostError( + "timeout must be non-zero (use None to clear)".into(), + )), + Some(d) => Ok(Some(u64::try_from(d.as_millis()).unwrap_or(u64::MAX))), + None => Ok(None), + } +} + +/// Set the read timeout. `None` clears it; `Some(Duration::ZERO)` is +/// rejected (matches `std::net::TcpStream::set_read_timeout`). +pub fn set_read_timeout( + stream: &StreamHandle, + timeout: Option, +) -> Result<(), SysError> { + let ms = to_host_timeout(timeout)?; + wit_net::net_set_read_timeout(stream.0, ms).map_err(SysError::HostError) +} + +/// Current read timeout, or `None` if unset. +pub fn read_timeout(stream: &StreamHandle) -> Result, SysError> { + Ok(wit_net::net_read_timeout(stream.0) + .map_err(SysError::HostError)? + .map(std::time::Duration::from_millis)) +} + +/// Set the write timeout. `None` clears it; `Some(Duration::ZERO)` is +/// rejected (matches `std::net::TcpStream::set_write_timeout`). +pub fn set_write_timeout( + stream: &StreamHandle, + timeout: Option, +) -> Result<(), SysError> { + let ms = to_host_timeout(timeout)?; + wit_net::net_set_write_timeout(stream.0, ms).map_err(SysError::HostError) +} + +/// Current write timeout, or `None` if unset. +pub fn write_timeout(stream: &StreamHandle) -> Result, SysError> { + Ok(wit_net::net_write_timeout(stream.0) + .map_err(SysError::HostError)? + .map(std::time::Duration::from_millis)) +} + +/// Set the IP TTL on outgoing packets. +pub fn set_ttl(stream: &StreamHandle, ttl: u32) -> Result<(), SysError> { + wit_net::net_set_ttl(stream.0, ttl).map_err(SysError::HostError) +} + +/// Current IP TTL. +pub fn ttl(stream: &StreamHandle) -> Result { + wit_net::net_ttl(stream.0).map_err(SysError::HostError) +} + +/// A connected TCP stream — the SDK analogue of [`std::net::TcpStream`]. +/// +/// Owns a [`StreamHandle`] from [`connect`] and implements +/// [`std::io::Read`] and [`std::io::Write`] over the host's byte-stream +/// `net-read-bytes` / `net-write-bytes` (no length-prefix framing). Generic +/// code that operates on any `Read + Write` (TLS clients, WebSocket +/// libraries, Postgres drivers) works unmodified. +/// +/// The host closes the underlying stream when this value is dropped, so +/// `TcpStream` is RAII — no explicit close required. +/// +/// # Example +/// +/// ```no_run +/// use astrid_sdk::net::TcpStream; +/// use std::io::{Read, Write}; +/// +/// let mut sock = TcpStream::connect("fulcrum.unicity.network:443")?; +/// sock.set_nodelay(true)?; +/// sock.write_all(b"GET / HTTP/1.1\r\nHost: example.com\r\n\r\n")?; +/// +/// let mut buf = vec![0u8; 4096]; +/// let n = sock.read(&mut buf)?; +/// # Ok::<(), std::io::Error>(()) +/// ``` +#[derive(Debug)] +pub struct TcpStream { + handle: StreamHandle, +} + +impl TcpStream { + /// Open a TCP connection to `addr`, formatted as `"host:port"`. + /// + /// DNS resolution and the SSRF airlock run host-side; the WASM guest + /// only sees the parsed host and port. + /// + /// # Errors + /// + /// Returns an [`std::io::Error`] wrapping the host-side failure + /// (capability denial, SSRF rejection, DNS failure, connect timeout, + /// or remote refusal). + pub fn connect>(addr: A) -> std::io::Result { + let (host, port) = parse_host_port(addr.as_ref())?; + let handle = connect(host, port).map_err(io_error_from_sys)?; + Ok(Self { handle }) + } + + /// Wrap an existing [`StreamHandle`] in the `TcpStream` facade. The + /// `TcpStream` takes ownership and will close the handle on drop. + #[must_use] + pub fn from_handle(handle: StreamHandle) -> Self { + Self { handle } + } + + /// The raw stream handle. Use the free functions in this module if you + /// need the byte-stream surface directly. + #[must_use] + pub fn handle(&self) -> &StreamHandle { + &self.handle + } + + /// Set the `TCP_NODELAY` socket option (Nagle's algorithm off when `true`). + pub fn set_nodelay(&self, nodelay: bool) -> std::io::Result<()> { + set_nodelay(&self.handle, nodelay).map_err(io_error_from_sys) + } + + /// Read the current `TCP_NODELAY` setting. + pub fn nodelay(&self) -> std::io::Result { + nodelay(&self.handle).map_err(io_error_from_sys) + } + + /// Set the read timeout. `None` clears it. + pub fn set_read_timeout(&self, timeout: Option) -> std::io::Result<()> { + set_read_timeout(&self.handle, timeout).map_err(io_error_from_sys) + } + + /// Current read timeout. + pub fn read_timeout(&self) -> std::io::Result> { + read_timeout(&self.handle).map_err(io_error_from_sys) + } + + /// Set the write timeout. `None` clears it. + pub fn set_write_timeout(&self, timeout: Option) -> std::io::Result<()> { + set_write_timeout(&self.handle, timeout).map_err(io_error_from_sys) + } + + /// Current write timeout. + pub fn write_timeout(&self) -> std::io::Result> { + write_timeout(&self.handle).map_err(io_error_from_sys) + } + + /// Set the IP `TTL` on outgoing packets. + pub fn set_ttl(&self, ttl_val: u32) -> std::io::Result<()> { + set_ttl(&self.handle, ttl_val).map_err(io_error_from_sys) + } + + /// Current IP `TTL`. + pub fn ttl(&self) -> std::io::Result { + ttl(&self.handle).map_err(io_error_from_sys) + } + + /// Remote peer address as `"ip:port"`. + pub fn peer_addr(&self) -> std::io::Result { + peer_addr(&self.handle).map_err(io_error_from_sys) + } + + /// Local socket address as `"ip:port"`. + pub fn local_addr(&self) -> std::io::Result { + local_addr(&self.handle).map_err(io_error_from_sys) + } + + /// Half-close the read side, write side, or both. + pub fn shutdown(&self, how: Shutdown) -> std::io::Result<()> { + shutdown(&self.handle, how).map_err(io_error_from_sys) + } + + /// Peek up to `buf.len()` bytes without consuming them. Returns the + /// number of bytes written into `buf`. `Ok(0)` is EOF (matches + /// `std::net::TcpStream::peek`). If a read timeout is set and it + /// expires with no data, returns + /// [`std::io::ErrorKind::WouldBlock`]. + pub fn peek(&self, buf: &mut [u8]) -> std::io::Result { + let max = u32::try_from(buf.len()).unwrap_or(u32::MAX); + let bytes = peek(&self.handle, max).map_err(io_error_from_net_op)?; + let n = buf.len().min(bytes.len()); + buf[..n].copy_from_slice(&bytes[..n]); + Ok(n) + } +} + +impl std::io::Read for TcpStream { + fn read(&mut self, buf: &mut [u8]) -> std::io::Result { + let max = u32::try_from(buf.len()).unwrap_or(u32::MAX); + let bytes = read_bytes(&self.handle, max).map_err(io_error_from_net_op)?; + let n = buf.len().min(bytes.len()); + buf[..n].copy_from_slice(&bytes[..n]); + Ok(n) + } +} + +impl std::io::Write for TcpStream { + fn write(&mut self, buf: &[u8]) -> std::io::Result { + let n = write_bytes(&self.handle, buf).map_err(io_error_from_net_op)?; + Ok(n as usize) + } + + fn flush(&mut self) -> std::io::Result<()> { + // The host writes are non-buffered at the SDK layer. + Ok(()) + } +} + +impl Drop for TcpStream { + fn drop(&mut self) { + let _ = close(&self.handle); + } +} + +/// Parse `"host:port"` for [`TcpStream::connect`]. Strips IPv6 square +/// brackets — `[::1]:443` → `("::1", 443)` — because the host fn takes +/// a raw hostname/IP without brackets (matching `tokio::net::lookup_host` +/// and `std`'s `(&str, u16)` `ToSocketAddrs` impl). +fn parse_host_port(addr: &str) -> std::io::Result<(&str, u16)> { + // IPv6 literal in `[v6]:port` form — split on the closing bracket + // so a v6 address with internal colons doesn't fool `rsplit_once`. + if let Some(end) = addr.strip_prefix('[') { + let (v6, rest) = end.split_once(']').ok_or_else(|| { + std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "missing closing `]` in IPv6 address", + ) + })?; + let port_str = rest.strip_prefix(':').ok_or_else(|| { + std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "IPv6 address must be followed by `:port`", + ) + })?; + let port = parse_port(port_str)?; + return Ok((v6, port)); + } + let (host, port_str) = addr.rsplit_once(':').ok_or_else(|| { + std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "address must be \"host:port\"", + ) + })?; + Ok((host, parse_port(port_str)?)) +} + +fn parse_port(port_str: &str) -> std::io::Result { + port_str.parse::().map_err(|e| { + std::io::Error::new( + std::io::ErrorKind::InvalidInput, + format!("invalid port: {e}"), + ) + }) +} + +fn io_error_from_sys(err: SysError) -> std::io::Error { + std::io::Error::other(err.to_string()) +} + +/// `io::Error` from a network-op `SysError`, mapping the host's +/// `"... would block"` sentinel to [`std::io::ErrorKind::WouldBlock`] +/// so std-style callers (`Read::read`, `Write::write`, `peek`) can +/// distinguish "timeout fired, retry" from a real EOF or transport +/// error. +fn io_error_from_net_op(err: SysError) -> std::io::Error { + let msg = err.to_string(); + if msg.contains("would block") { + std::io::Error::new(std::io::ErrorKind::WouldBlock, msg) + } else { + std::io::Error::other(msg) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_host_port_basic() { + let (h, p) = parse_host_port("example.com:443").unwrap(); + assert_eq!((h, p), ("example.com", 443)); + } + + #[test] + fn parse_host_port_strips_ipv6_brackets() { + let (h, p) = parse_host_port("[::1]:443").unwrap(); + assert_eq!((h, p), ("::1", 443)); + let (h, p) = parse_host_port("[2001:db8::1]:8080").unwrap(); + assert_eq!((h, p), ("2001:db8::1", 8080)); + } + + #[test] + fn parse_host_port_ipv6_without_brackets_fails_cleanly() { + // `2001:db8::1:443` is ambiguous (no brackets, multiple colons). + // rsplit_once takes the last colon — the port parse then fails + // because `:1` precedes it. We accept this; brackets are the + // unambiguous form. + assert!(parse_host_port("2001:db8::1:abc").is_err()); + } + + #[test] + fn parse_host_port_missing_close_bracket() { + assert!(parse_host_port("[::1:443").is_err()); + } + + #[test] + fn parse_host_port_invalid_port_rejected() { + assert!(parse_host_port("example.com:notaport").is_err()); + assert!(parse_host_port("example.com:99999").is_err()); // exceeds u16 + } + + #[test] + fn to_host_timeout_rejects_zero() { + let err = to_host_timeout(Some(std::time::Duration::ZERO)).unwrap_err(); + assert!(err.to_string().contains("non-zero")); + } + + #[test] + fn to_host_timeout_passes_through() { + assert_eq!(to_host_timeout(None).unwrap(), None); + assert_eq!( + to_host_timeout(Some(std::time::Duration::from_millis(500))).unwrap(), + Some(500) + ); + } + + #[test] + fn io_error_from_net_op_maps_would_block() { + let err = io_error_from_net_op(SysError::HostError("read would block".into())); + assert_eq!(err.kind(), std::io::ErrorKind::WouldBlock); + } + + #[test] + fn io_error_from_net_op_other_passes_through() { + let err = io_error_from_net_op(SysError::HostError("dns: no addresses".into())); + assert_eq!(err.kind(), std::io::ErrorKind::Other); + } +} diff --git a/astrid-sys/wit/astrid-capsule.wit b/astrid-sys/wit/astrid-capsule.wit index b1963b4..05db395 100644 --- a/astrid-sys/wit/astrid-capsule.wit +++ b/astrid-sys/wit/astrid-capsule.wit @@ -352,6 +352,20 @@ interface types { /// Whether the action was approved. approved: bool, } + + /// Direction argument for `net-shutdown` — mirrors `std::net::Shutdown`. + enum shutdown-how { + /// Half-close the read side. Subsequent reads return EOF; writes + /// continue. + read, + /// Half-close the write side. The peer sees EOF on its read side; + /// reads continue. + write, + /// Close both directions. Equivalent to `net-close-stream` except + /// the handle entry is retained so getters (`peer-addr`, etc.) + /// still work until the explicit close call. + both, + } } // --------------------------------------------------------------------------- @@ -531,7 +545,7 @@ interface kv { /// /// Max 8 concurrent streams per capsule. interface net { - use types.{net-read-status}; + use types.{net-read-status, shutdown-how}; /// Bind and activate the pre-provisioned Unix socket listener. /// @@ -569,6 +583,122 @@ interface net { /// Idempotent: closing an already-closed handle is a no-op. /// Publishes a `client.v1.disconnect` IPC event. net-close-stream: func(stream-handle: u64) -> result<_, string>; + + /// Open an outbound TCP connection to `host:port`. + /// + /// Returns a stream handle compatible with the existing + /// `net-read` / `net-write` / `net-close-stream` functions + /// — the same handle type returned by `net-accept`. + /// + /// Security gates (all checked before any TCP syscall): + /// - The capsule's `net_connect` capability allowlist must + /// contain a pattern matching `host:port`. Patterns are + /// `"host:port"` exact matches or `"host:*"` (any port for + /// the named host). Missing or empty allowlist denies all + /// outbound TCP (fail-closed). + /// - DNS resolution runs after the capability check. The + /// resolved IP is rejected if it falls into a private, + /// loopback, link-local, multicast, or unspecified range, + /// matching the airlock that gates `http-request` / + /// `http-stream-start`. + /// - The capsule's per-instance active-stream cap (default + /// 8, shared with `net-accept`) is enforced. + /// - Connect attempts time out (default 10s) rather than + /// holding the WASM guest in a host fn indefinitely. + net-connect-tcp: func(host: string, port: u16) -> result; + + /// Read up to `max-bytes` from `stream` without length-prefix framing. + /// + /// Mirrors `std::net::TcpStream::read` (and `::read`). + /// Returns the bytes that were available — may be shorter than + /// `max-bytes`. Empty list means no data is ready under the current + /// read timeout (default: non-blocking, ~50 ms internal poll). + /// + /// Use this for byte-stream protocols (WebSocket, MQTT, postgres, + /// HTTP/1.1, TLS, …). The existing `net-read` keeps the length-prefix + /// framing required by the uplink-proxy use case. + net-read-bytes: func(stream-handle: u64, max-bytes: u32) -> result, string>; + + /// Write `data` to `stream` without length-prefix framing. + /// + /// Mirrors `::write`. Returns the number of + /// bytes actually written — may be less than `data.len()` when the + /// kernel-side socket buffer is full. Callers loop until `data` is + /// drained (the SDK `write_all` wrapper handles this). + net-write-bytes: func(stream-handle: u64, data: list) -> result; + + /// Read up to `max-bytes` from `stream` without consuming them. + /// + /// Mirrors `std::net::TcpStream::peek`. Subsequent `net-read-bytes` + /// returns the same bytes again. Useful for protocol detection on + /// the first frame of a connection. + net-peek: func(stream-handle: u64, max-bytes: u32) -> result, string>; + + /// Shut down the read side, write side, or both halves of `stream`. + /// + /// Mirrors `std::net::TcpStream::shutdown`. After `shutdown-how::write` + /// the peer sees EOF on its read side; further sends on the local + /// side return an error. `shutdown-how::both` closes both directions + /// but leaves the handle entry intact so accessors (e.g. + /// `peer-addr`) still work until `net-close-stream` is called. + net-shutdown: func(stream-handle: u64, how: shutdown-how) -> result<_, string>; + + /// Return the remote peer address as `"ip:port"`. + /// + /// Mirrors `std::net::TcpStream::peer_addr`. Returns an error for + /// Unix-domain stream handles. + net-peer-addr: func(stream-handle: u64) -> result; + + /// Return the local socket address as `"ip:port"`. + /// + /// Mirrors `std::net::TcpStream::local_addr`. Returns an error for + /// Unix-domain stream handles. + net-local-addr: func(stream-handle: u64) -> result; + + /// Enable or disable the `TCP_NODELAY` option (Nagle's algorithm off + /// when `true`). + /// + /// Mirrors `std::net::TcpStream::set_nodelay`. Returns an error for + /// Unix-domain stream handles. + net-set-nodelay: func(stream-handle: u64, nodelay: bool) -> result<_, string>; + + /// Return the current `TCP_NODELAY` setting. + /// + /// Mirrors `std::net::TcpStream::nodelay`. + net-nodelay: func(stream-handle: u64) -> result; + + /// Set the read timeout. `none` clears the timeout (reads still + /// honour the host's internal cancellation token; this controls + /// only the user-visible blocking duration of each `net-read-bytes` + /// call). + /// + /// Mirrors `std::net::TcpStream::set_read_timeout`. Subsequent + /// `net-read-bytes` and `net-peek` calls honour the new value. + net-set-read-timeout: func(stream-handle: u64, timeout-ms: option) -> result<_, string>; + + /// Return the current read timeout in milliseconds, or `none` if + /// unset. + net-read-timeout: func(stream-handle: u64) -> result, string>; + + /// Set the write timeout. `none` clears it. + /// + /// Mirrors `std::net::TcpStream::set_write_timeout`. + net-set-write-timeout: func(stream-handle: u64, timeout-ms: option) -> result<_, string>; + + /// Return the current write timeout in milliseconds, or `none` if + /// unset. + net-write-timeout: func(stream-handle: u64) -> result, string>; + + /// Set the IP `TTL` field on outgoing packets. + /// + /// Mirrors `std::net::TcpStream::set_ttl`. Returns an error for + /// Unix-domain stream handles. + net-set-ttl: func(stream-handle: u64, ttl: u32) -> result<_, string>; + + /// Return the current IP TTL. + /// + /// Mirrors `std::net::TcpStream::ttl`. + net-ttl: func(stream-handle: u64) -> result; } /// HTTP client operations with SSRF protection. diff --git a/contracts b/contracts index 65bd8e7..9244c74 160000 --- a/contracts +++ b/contracts @@ -1 +1 @@ -Subproject commit 65bd8e72539b18d804609f31c35cb96ca42fe71f +Subproject commit 9244c74c4160a30a8029d06dbe0a857f4e3b4a2a