Skip to content

feat(wasm): outbound TCP for core modules (wippy:sock/tcp) - #543

Open
skhaz wants to merge 2 commits into
mainfrom
feat/pyodide-wasm-runtime
Open

feat(wasm): outbound TCP for core modules (wippy:sock/tcp)#543
skhaz wants to merge 2 commits into
mainfrom
feat/pyodide-wasm-runtime

Conversation

@skhaz

@skhaz skhaz commented Jul 31, 2026

Copy link
Copy Markdown
Member

Why

wasi:sockets and wasi:http are both ComponentOnly, so a core wasm module has no way to reach the network at all. A language runtime compiled to wasm32-wasip1 is 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:

requests.get("https://…")   ImportError: Can't connect to HTTPS URL because the SSL module is not available
socket.create_connection    AttributeError: module '_socket' has no attribute 'getaddrinfo'

What

coresock exposes connect / send / recv / close over raw integer signatures — what a core import can express — carrying 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, 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 responded for kind function.wasm while wazero is still inside ssa.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 provides RegisterCoreFunc and 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:

ok  ssl module      -> OpenSSL 3.6.3 TLS1.3=True
ok  clock           -> year 2026
ok  requests        -> status=200 bytes=559
ok  urllib          -> status=200 bytes=559
ok  http.client     -> status=200 bytes=559
ok  expired cert    -> rejected: SSLError

Against varied real endpoints: https://1.1.1.1 verifies against an IP SAN (200), https://github.com (200), https://pypi.org/simple/ streams 44 MB, and a POST to httpbin returns its JSON echoed. Certificate verification is real, not bypassed.

./runtime/wasm/... and ./boot/components/runtime/... are green. TestDefaultHostProfiles asserted a profile count, so it now names the new profile rather than only counting.

skhaz added 2 commits July 31, 2026 16:44
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.
@skhaz

skhaz commented Jul 31, 2026

Copy link
Copy Markdown
Member Author

Proof that the wasm guest obeys the same network rules as Lua

A guest was previously dialed with a plain net.Dialer, so it ignored the network options every host-side caller obeys. In an application routed through an overlay that is a leak: the guest reaches clearnet, exposing the DNS lookup and the target address the overlay exists to hide.

The dial now resolves exactly as runtime/lua/modules/httpclient and service/http/client do — netapi.GetDefaultNetwork on the frame, which already carries the effective value after per-call options.network, per-entry meta.options.network and the app default have been merged. The registry is read from the call context, not captured at boot, so a guest follows the network its own frame selected.

Rule-by-rule

Rule Lua / HTTP dispatcher wasm guest (this PR)
Overlay selection source netapi.GetDefaultNetwork(ctx) same
network.select permission required required
Overlay requested, registry missing refused, no clearnet fallback refused, no clearnet fallback
Local DNS for overlay targets skipped (would leak the hostname) skipped
Private-address policy http_client.private_ip same

Test output

=== RUN   TestOverlayNetworkIsUsedWhenSelected
--- PASS: TestOverlayNetworkIsUsedWhenSelected (0.00s)
=== RUN   TestOverlayRequestedWithoutRegistryIsRefused
    refused: overlay network "network:tor" requested but no network registry is configured
--- PASS: TestOverlayRequestedWithoutRegistryIsRefused (0.00s)
=== RUN   TestPrivateAddressNeedsPermission
    refused: not allowed: private IP 127.0.0.1
--- PASS: TestPrivateAddressNeedsPermission (0.00s)
=== RUN   TestOverlaySelectionNeedsPermission
    refused: not allowed: network network:tor
--- PASS: TestOverlaySelectionNeedsPermission (0.00s)
ok      github.com/wippyai/runtime/runtime/wasm/host/wippy/hosts/coresock

TestOverlayNetworkIsUsedWhenSelected asserts the overlay dialer is the one actually called, with the address it received; TestOverlaySelectionNeedsPermission asserts that when the permission is denied the overlay dialer is not called and the dial fails, rather than being downgraded.

Also in this push

The two lint findings from the earlier run are fixed: a fieldalignment report on an anonymous struct (replaced with explicit registration calls) and a British spelling flagged by misspell. ./runtime/wasm/... and ./boot/components/runtime/... are green, and golangci-lint reports 0 issues for the touched packages.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant