feat(wasm): outbound TCP for core modules (wippy:sock/tcp) - #543
Conversation
wasi:sockets and wasi:http are both ComponentOnly, so a core wasm module had no way to reach the network at all. A language runtime compiled to wasm32-wasip1 is a core module, which meant TLS was impossible inside it: WASI Preview 1 provides no outbound socket and no resolver, so an interpreter's own HTTP stack fails before TLS starts. Adds coresock, a host exposing connect/send/recv/close over raw integer signatures — what a core import can express — with the private-address policy the Lua http_client already applies, so a guest cannot use the host as a way into the local network. Every dial is additionally gated on wippy.sock.connect. TCP rather than HTTP deliberately. With a byte stream the guest keeps its own TLS and its own protocol code, so a standard library works unmodified instead of every client library needing a special case. Registered synchronously on purpose. Declaring the calls async makes the engine asyncify-transform the whole guest before compiling it, and instrumenting a 10 MB CPython pushes wazero's compile past the 30s entry-load timeout — which surfaces as "no listener responded" for kind function.wasm while wazero is still inside ssa.RunPasses. The guest call is synchronous anyway, so the host blocking on the socket is the required behaviour. Requires wasm-runtime 289ea70 (#13) for RegisterCoreFunc and for the host clock: wazero's default fake clock made TLS reject every certificate as "not yet valid". Verified end to end on the runtime with a CPython guest: requests, urllib and http.client each complete an HTTPS GET (200), a POST returns its JSON echoed, https://1.1.1.1 verifies against an IP SAN, pypi.org/simple/ streams 44 MB, and an expired certificate is rejected.
A guest was dialed with a plain net.Dialer, so it ignored the network options every host-side caller obeys. In an application configured to route through an overlay that is a leak: the guest would reach clearnet, exposing DNS and the target address the overlay exists to hide. The dial now resolves the same way the Lua http_client and the HTTP dispatcher do: netapi.GetDefaultNetwork on the frame, which already carries the effective value after per-call options, per-entry meta.options.network and the app default have been merged. The network registry is read from the call context rather than captured at boot, so a guest follows the network its frame selected. Matching the host-side rules exactly: - network.select gates overlay use; without it the dial is refused, not downgraded. - An overlay requested with no registry configured is refused rather than dialed over clearnet, because a silent downgrade defeats the overlay. - No local DNS lookup happens for an overlay dial: the overlay resolves at its far end, and a local lookup would leak the hostname to the system resolver. - The private-address policy still applies, since an overlay is not a way around it. Four tests assert those, including that the overlay dialer is the one actually called and that a denied permission leaves it uncalled.
Proof that the wasm guest obeys the same network rules as LuaA guest was previously dialed with a plain The dial now resolves exactly as Rule-by-rule
Test output
Also in this pushThe two lint findings from the earlier run are fixed: a |
Why
wasi:socketsandwasi:httpare bothComponentOnly, so a core wasm module has no way to reach the network at all. A language runtime compiled towasm32-wasip1is a core module, which made TLS impossible inside it: WASI Preview 1 provides no outbound socket and no resolver, so the interpreter's own HTTP stack fails before TLS even starts.Concretely, in a CPython guest before this change:
What
coresockexposesconnect/send/recv/closeover raw integer signatures — what a core import can express — carrying the private-address policy the Luahttp_clientalready applies, so a guest cannot use the host as a way into the local network. Every dial is additionally gated onwippy.sock.connect.TCP rather than HTTP, deliberately. With a byte stream the guest keeps its own TLS and its own protocol code, so a standard library works unmodified instead of every client library needing a special case.
Registered synchronously, deliberately. Declaring the calls async makes the engine asyncify-transform the whole guest before compiling it, and instrumenting a 10 MB CPython pushes wazero's compile past the 30s entry-load timeout — which surfaces as
no listener respondedfor kindfunction.wasmwhile wazero is still insidessa.RunPasses. A goroutine dump was the only way to tell that apart from a deadlock. The guest call is synchronous anyway, so the host blocking on the socket is the required behaviour.Depends on
wasm-runtime #13, merged as
289ea70, pinned here. It providesRegisterCoreFuncand the host clock — wazero's default fake clock reports 1970, which made TLS reject every certificate as "not yet valid or the system clock is incorrect".Evidence
End to end on this runtime, driving a CPython guest from Lua:
Against varied real endpoints:
https://1.1.1.1verifies against an IP SAN (200),https://github.com(200),https://pypi.org/simple/streams 44 MB, and aPOSTto httpbin returns its JSON echoed. Certificate verification is real, not bypassed../runtime/wasm/...and./boot/components/runtime/...are green.TestDefaultHostProfilesasserted a profile count, so it now names the new profile rather than only counting.