From f5039e3acfc16bc16891f19050e91d61330ebe57 Mon Sep 17 00:00:00 2001 From: "Joshua J. Bouw" Date: Tue, 19 May 2026 13:13:46 +0400 Subject: [PATCH 1/2] feat(net): add net-connect-tcp for outbound TCP MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds one host fn — net-connect-tcp(host, port) -> stream-handle — to the astrid:capsule/net interface. Returns the same handle type as net-accept, so net-read / net-write / net-close-stream already work with it. Capability gate: per-capsule net_connect = ["host:port"] allowlist in Capsule.toml (defined kernel-side, this RFC describes the wire shape only). SSRF airlock runs on the resolved IP, matching the gate on http-request. Connect timeout bounded to 10s default. Unblocks WebSocket, MQTT, Discord/Telegram gateways, postgres/redis, and the immediate forcing function: a Unicity-network capsule wrapping Sphere SDK (Fulcrum + Nostr persistent WebSocket). RFC: unicity-astrid/rfcs#27 Tracking issue: unicity-astrid/astrid#745 --- host/astrid-capsule.wit | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/host/astrid-capsule.wit b/host/astrid-capsule.wit index b1963b4..aa07e3e 100644 --- a/host/astrid-capsule.wit +++ b/host/astrid-capsule.wit @@ -569,6 +569,29 @@ 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; } /// HTTP client operations with SSRF protection. From 215ba1c9d9ea5051d955bb6c565e9c555f12e025 Mon Sep 17 00:00:00 2001 From: "Joshua J. Bouw" Date: Tue, 19 May 2026 15:06:54 +0400 Subject: [PATCH 2/2] feat(net): expand to full std::net::TcpStream surface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds 14 functions + shutdown-how enum to astrid:capsule/net for parity with std::net::TcpStream: - net-read-bytes / net-write-bytes: byte-stream read/write (no length-prefix framing — distinct from the legacy framed net-read / net-write used by the inbound Unix-accept proxy) - net-peek: peek without consuming - net-shutdown(read|write|both): half-close - net-peer-addr / net-local-addr: address introspection - net-set-nodelay / net-nodelay: TCP_NODELAY - net-set-read-timeout / net-read-timeout - net-set-write-timeout / net-write-timeout - net-set-ttl / net-ttl: IP TTL Existing net-read / net-write keep their framed semantics (the CLI proxy uplink remains structurally framed). Both pairs operate on the same stream-handle type — capsule code picks the matching pair for its protocol. Unblocks generic Read+Write consumers — tungstenite, rustls, postgres drivers — to drop in unmodified. RFC: unicity-astrid/rfcs#27 --- host/astrid-capsule.wit | 109 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 108 insertions(+), 1 deletion(-) diff --git a/host/astrid-capsule.wit b/host/astrid-capsule.wit index aa07e3e..05db395 100644 --- a/host/astrid-capsule.wit +++ b/host/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. /// @@ -592,6 +606,99 @@ interface net { /// - 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.