diff --git a/boot/components/runtime/wasm/engine_test.go b/boot/components/runtime/wasm/engine_test.go index 6a5df905a..65dcbf051 100644 --- a/boot/components/runtime/wasm/engine_test.go +++ b/boot/components/runtime/wasm/engine_test.go @@ -56,6 +56,9 @@ func TestDefaultHostProfiles(t *testing.T) { "funcs", "wasi1", "wasi:io", "wasi:poll", "wasi:clocks", "wasi:cli", "wasi:filesystem", "wasi:random", "wasi:sockets", "wasi:http", + // Core modules cannot import wasi:sockets or wasi:http, both of which are + // component-only, so they get outbound TCP through this one instead. + "wippy:sock/tcp", } if len(profiles) != len(want) { t.Fatalf("DefaultHostProfiles() len = %d, want %d", len(profiles), len(want)) diff --git a/boot/components/runtime/wasm/hosts.go b/boot/components/runtime/wasm/hosts.go index 8d96f3658..c55811851 100644 --- a/boot/components/runtime/wasm/hosts.go +++ b/boot/components/runtime/wasm/hosts.go @@ -5,9 +5,12 @@ package wasm import ( "context" + "github.com/tetratelabs/wazero/api" + "github.com/wippyai/runtime/api/dispatcher" runtimewasm "github.com/wippyai/runtime/runtime/wasm" wasmcomponent "github.com/wippyai/runtime/runtime/wasm/component" + "github.com/wippyai/runtime/runtime/wasm/host/wippy/hosts/coresock" wasmrt "github.com/wippyai/wasm-runtime/runtime" "github.com/wippyai/wasm-runtime/wasi/preview2" "go.uber.org/zap" @@ -29,6 +32,7 @@ func DefaultHostProfiles(log *zap.Logger, disp dispatcher.Dispatcher) []wasmcomp wasiRandomProfile(log), wasiSocketsProfile(log), wasiHTTPProfile(disp, log), + coreSockProfile(log), } } @@ -151,3 +155,52 @@ func wasiHTTPProfile(d dispatcher.Dispatcher, log *zap.Logger) wasmcomponent.Hos }, } } + +// coreSockProfile exposes outbound TCP to core modules. +// +// Deliberately not ComponentOnly: wasi:sockets and wasi:http both are, so a core +// module has no way to reach the network at all. The signatures are raw integers +// because that is what a core import can express, and the calls are async so the +// guest yields while the host waits on the connection. +func coreSockProfile(log *zap.Logger) wasmcomponent.HostProfile { + return wasmcomponent.HostProfile{ + Name: coresock.Namespace, + Aliases: []string{coresock.Namespace, "wippy:sock"}, + Register: func(_ context.Context, rt *wasmrt.Runtime) error { + host := coresock.New(log) + i32, i64 := api.ValueTypeI32, api.ValueTypeI64 + + // Registered synchronous on purpose. Declaring them async makes the + // engine asyncify-transform the whole guest, and instrumenting a 10 MB + // interpreter pushes wazero's compile past the runtime's 30s entry-load + // timeout. The guest call is synchronous anyway, so the host blocking on + // the socket is exactly the required behavior. + register := func(name string, params, results []api.ValueType, fn api.GoModuleFunc) error { + if err := rt.RegisterCoreFunc(coresock.Namespace, name, params, results, fn, false); err != nil { + return runtimewasm.NewRegisterHostError(coresock.Namespace, err) + } + return nil + } + + if err := register("connect", + []api.ValueType{i32, i32, i32, i32}, []api.ValueType{i64}, host.Connect); err != nil { + return err + } + if err := register("send", + []api.ValueType{i32, i32, i32}, []api.ValueType{i64}, host.Send); err != nil { + return err + } + if err := register("recv", + []api.ValueType{i32, i32, i32}, []api.ValueType{i64}, host.Recv); err != nil { + return err + } + if err := register("close", + []api.ValueType{i32}, []api.ValueType{i32}, host.Close); err != nil { + return err + } + + log.Info("wasm host profile registered", zap.String("profile", coresock.Namespace)) + return nil + }, + } +} diff --git a/go.mod b/go.mod index 5b2c1197a..c5dbe45c1 100644 --- a/go.mod +++ b/go.mod @@ -68,7 +68,7 @@ require ( github.com/wippyai/tree-sitter-markdown v0.0.3 github.com/wippyai/tree-sitter-sql v0.0.4 github.com/wippyai/wapp v0.1.2 - github.com/wippyai/wasm-runtime v0.0.0-20260719152804-ec6962b31c64 + github.com/wippyai/wasm-runtime v0.0.0-20260731193643-289ea709d8e8 github.com/xuri/excelize/v2 v2.11.0 go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 go.opentelemetry.io/otel v1.44.0 diff --git a/go.sum b/go.sum index c4be0eaf8..b079c05fc 100644 --- a/go.sum +++ b/go.sum @@ -562,6 +562,8 @@ github.com/wippyai/wapp v0.1.2 h1:fCoxKr9s3gk+pWx4XcnIQmOVgPiuimXf3QcE9mHbdmw= github.com/wippyai/wapp v0.1.2/go.mod h1:ndCkYR80+osLGbd7AFWlP+3DxwooR+R6cxQYPZhksg4= github.com/wippyai/wasm-runtime v0.0.0-20260719152804-ec6962b31c64 h1:ETP3ZooZzg4hUZYnfz/ZPdqvpWAduQ9nHK+XqkOBkj8= github.com/wippyai/wasm-runtime v0.0.0-20260719152804-ec6962b31c64/go.mod h1:81IhzsGQBNRyLqynOAYK9WLsMx9CoqtwsEf0B6PwL5k= +github.com/wippyai/wasm-runtime v0.0.0-20260731193643-289ea709d8e8 h1:ELbGKx6QgYh1TUw7rLEv7AqndO9deBbGzmXhBb6Vvpw= +github.com/wippyai/wasm-runtime v0.0.0-20260731193643-289ea709d8e8/go.mod h1:81IhzsGQBNRyLqynOAYK9WLsMx9CoqtwsEf0B6PwL5k= github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= diff --git a/runtime/wasm/host/wippy/hosts/coresock/coresock.go b/runtime/wasm/host/wippy/hosts/coresock/coresock.go new file mode 100644 index 000000000..bed99fdbc --- /dev/null +++ b/runtime/wasm/host/wippy/hosts/coresock/coresock.go @@ -0,0 +1,306 @@ +// SPDX-License-Identifier: MPL-2.0 + +// Package coresock gives core wasm modules an outbound TCP capability. +// +// WASI Preview 1 has no outbound socket and no resolver, so a guest built against it +// cannot connect however it is linked. wasi:sockets answers this for components, but +// a core module cannot import it. This host is the core-module equivalent: the host +// owns the connection and the guest moves bytes through its own memory. +// +// TCP rather than HTTP on purpose. With a byte stream the guest keeps its own TLS and +// its own protocol code, so an interpreter's standard library works unmodified +// instead of every HTTP client needing a special case. +// +// Integer-only signatures, because that is all a core import can express: +// +// connect(host_ptr, host_len, port, timeout_ms) -> i64 status<<32 | handle +// send(handle, buf_ptr, buf_len) -> i64 status<<32 | written +// recv(handle, out_ptr, out_cap) -> i64 status<<32 | read (0=EOF) +// close(handle) -> i32 status +package coresock + +import ( + "context" + "fmt" + "net" + "strings" + "sync" + "time" + + "github.com/tetratelabs/wazero/api" + "go.uber.org/zap" + + netapi "github.com/wippyai/runtime/api/net" + "github.com/wippyai/runtime/api/registry" + "github.com/wippyai/runtime/runtime/security" +) + +// Namespace is the import module name a core guest declares. +const Namespace = "wippy:sock/tcp" + +// Status codes returned in the high half of the i64 result. +const ( + StatusOK uint32 = 0 + StatusBadRequest uint32 = 1 + StatusDenied uint32 = 2 + StatusTransport uint32 = 3 + StatusUnknownHandle uint32 = 4 + StatusTooMany uint32 = 5 +) + +const ( + maxHostLen = 253 + maxChunkBytes = 1 << 20 + maxConnections = 16 + defaultTimeout = 30 * time.Second + // connectAction gates every dial, so a guest that was not granted it cannot + // reach the network even though the host can. + connectAction = "wippy.sock.connect" +) + +// Host serves TCP calls for core modules. +type Host struct { + log *zap.Logger + conns map[uint32]net.Conn + mu sync.Mutex + next uint32 +} + +// New builds a TCP host. +func New(log *zap.Logger) *Host { + return &Host{log: log, conns: make(map[uint32]net.Conn)} +} + +// dial resolves the same network options the Lua http_client honors, so a guest is +// routed exactly like host-side code in the same application. +// +// An overlay resolves DNS at its far end, so no local lookup happens for it: a local +// lookup would leak the target to the system resolver and defeat the overlay. The +// private-address policy still applies, because an overlay is not a way around it. +func (h *Host) dial(ctx context.Context, host, target string, timeout time.Duration) (net.Conn, error) { + overlayID := netapi.GetDefaultNetwork(ctx) + if overlayID == "" { + if err := checkTarget(ctx, host); err != nil { + return nil, err + } + dialer := net.Dialer{Timeout: timeout} + return dialer.DialContext(ctx, "tcp", target) + } + + if !security.IsAllowed(ctx, "network.select", overlayID, nil) { + return nil, fmt.Errorf("not allowed: network %s", overlayID) + } + // Refuse to fall back to clearnet when an overlay was asked for: falling back + // would leak DNS and the target address to the local network. + // Resolved per call, like the HTTP dispatcher does, so the guest follows the + // network the surrounding frame selected rather than one fixed at boot. + networkReg := netapi.GetNetworkRegistry(ctx) + if networkReg == nil { + return nil, fmt.Errorf("overlay network %q requested but no network registry is configured", overlayID) + } + svc, err := networkReg.GetNetwork(registry.ParseID(overlayID)) + if err != nil { + return nil, fmt.Errorf("overlay network %q: %w", overlayID, err) + } + if ip := net.ParseIP(host); ip != nil && isPrivateIP(ip) && + !security.IsAllowed(ctx, "http_client.private_ip", host, nil) { + return nil, fmt.Errorf("not allowed: private IP %s", host) + } + + dialCtx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + return svc.DialContext(dialCtx, "tcp", target) +} + +func pack(status, value uint32) uint64 { + return uint64(status)<<32 | uint64(value) +} + +func isPrivateIP(ip net.IP) bool { + if ip == nil { + return false + } + return ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() || + ip.IsLinkLocalMulticast() || ip.IsUnspecified() +} + +// checkTarget mirrors the Lua http_client policy: a private address needs its own +// permission, so a guest cannot use the host as a way into the local network. +func checkTarget(ctx context.Context, host string) error { + if host == "" || len(host) > maxHostLen { + return fmt.Errorf("host is empty or too long") + } + if ip := net.ParseIP(host); ip != nil { + if isPrivateIP(ip) && !security.IsAllowed(ctx, "http_client.private_ip", host, nil) { + return fmt.Errorf("not allowed: private IP %s", host) + } + return nil + } + ips, err := net.DefaultResolver.LookupIP(ctx, "ip", host) + if err != nil { + return fmt.Errorf("cannot resolve %s", host) + } + for _, ip := range ips { + if isPrivateIP(ip) && !security.IsAllowed(ctx, "http_client.private_ip", ip.String(), nil) { + return fmt.Errorf("not allowed: private IP %s", ip.String()) + } + } + return nil +} + +// Connect dials a TCP connection and retains it behind a handle. +func (h *Host) Connect(ctx context.Context, mod api.Module, stack []uint64) { + hostPtr, hostLen := uint32(stack[0]), uint32(stack[1]) + port, timeoutMS := uint32(stack[2]), uint32(stack[3]) + + if hostLen == 0 || hostLen > maxHostLen || port == 0 || port > 65535 { + stack[0] = pack(StatusBadRequest, 0) + return + } + raw, ok := mod.Memory().Read(hostPtr, hostLen) + if !ok { + stack[0] = pack(StatusBadRequest, 0) + return + } + host := string(raw) + target := net.JoinHostPort(host, fmt.Sprintf("%d", port)) + + if !security.IsAllowed(ctx, connectAction, target, nil) { + stack[0] = pack(StatusDenied, 0) + return + } + h.mu.Lock() + live := len(h.conns) + h.mu.Unlock() + if live >= maxConnections { + stack[0] = pack(StatusTooMany, 0) + return + } + + timeout := defaultTimeout + if timeoutMS > 0 { + timeout = time.Duration(timeoutMS) * time.Millisecond + } + conn, err := h.dial(ctx, host, target, timeout) + if err != nil { + if h.log != nil { + h.log.Debug("core socket connect refused", zap.String("target", target), zap.Error(err)) + } + if strings.Contains(err.Error(), "not allowed") || strings.Contains(err.Error(), "overlay network") { + stack[0] = pack(StatusDenied, 0) + return + } + stack[0] = pack(StatusTransport, 0) + return + } + + h.mu.Lock() + h.next++ + if h.next == 0 { + h.next = 1 + } + handle := h.next + h.conns[handle] = conn + h.mu.Unlock() + + stack[0] = pack(StatusOK, handle) +} + +func (h *Host) conn(handle uint32) (net.Conn, bool) { + h.mu.Lock() + defer h.mu.Unlock() + c, ok := h.conns[handle] + return c, ok +} + +// Send writes one chunk from guest memory. +func (h *Host) Send(_ context.Context, mod api.Module, stack []uint64) { + handle, bufPtr, bufLen := uint32(stack[0]), uint32(stack[1]), uint32(stack[2]) + + conn, ok := h.conn(handle) + if !ok { + stack[0] = pack(StatusUnknownHandle, 0) + return + } + if bufLen > maxChunkBytes { + stack[0] = pack(StatusBadRequest, 0) + return + } + payload, ok := mod.Memory().Read(bufPtr, bufLen) + if !ok { + stack[0] = pack(StatusBadRequest, 0) + return + } + + written, err := conn.Write(payload) + if err != nil { + stack[0] = pack(StatusTransport, uint32(written)) + return + } + stack[0] = pack(StatusOK, uint32(written)) +} + +// Recv reads one chunk into guest memory. A read of zero means the peer closed. +func (h *Host) Recv(_ context.Context, mod api.Module, stack []uint64) { + handle, outPtr, outCap := uint32(stack[0]), uint32(stack[1]), uint32(stack[2]) + + conn, ok := h.conn(handle) + if !ok { + stack[0] = pack(StatusUnknownHandle, 0) + return + } + if outCap == 0 || outCap > maxChunkBytes { + stack[0] = pack(StatusBadRequest, 0) + return + } + + buf := make([]byte, outCap) + n, err := conn.Read(buf) + if n > 0 { + if !mod.Memory().Write(outPtr, buf[:n]) { + stack[0] = pack(StatusBadRequest, 0) + return + } + } + if err != nil && n == 0 { + // EOF is not a transport failure: it is how the peer says it is done. + stack[0] = pack(StatusOK, 0) + return + } + stack[0] = pack(StatusOK, uint32(n)) +} + +// Close releases a connection. +func (h *Host) Close(_ context.Context, _ api.Module, stack []uint64) { + handle := uint32(stack[0]) + + h.mu.Lock() + conn, ok := h.conns[handle] + delete(h.conns, handle) + h.mu.Unlock() + + if !ok { + stack[0] = uint64(StatusUnknownHandle) + return + } + _ = conn.Close() + stack[0] = uint64(StatusOK) +} + +// CloseAll drops every retained connection; called when an instance goes away. +func (h *Host) CloseAll() { + h.mu.Lock() + conns := h.conns + h.conns = make(map[uint32]net.Conn) + h.mu.Unlock() + for _, c := range conns { + _ = c.Close() + } +} + +// LiveConnections reports retained connections, for tests and diagnostics. +func (h *Host) LiveConnections() int { + h.mu.Lock() + defer h.mu.Unlock() + return len(h.conns) +} diff --git a/runtime/wasm/host/wippy/hosts/coresock/coresock_test.go b/runtime/wasm/host/wippy/hosts/coresock/coresock_test.go new file mode 100644 index 000000000..291a1cbbf --- /dev/null +++ b/runtime/wasm/host/wippy/hosts/coresock/coresock_test.go @@ -0,0 +1,138 @@ +// SPDX-License-Identifier: MPL-2.0 + +package coresock + +import ( + "context" + "errors" + "net" + "testing" + "time" + + ctxapi "github.com/wippyai/runtime/api/context" + netapi "github.com/wippyai/runtime/api/net" + "github.com/wippyai/runtime/api/registry" + securityapi "github.com/wippyai/runtime/api/security" +) + +// overlayContext selects an overlay network the way the runtime does: the effective +// ID lives on the FrameContext, written there by the executor from per-call options, +// per-entry meta.options.network, or the app default. +func overlayContext(t *testing.T, networkID string, permitted bool) context.Context { + t.Helper() + ctx := ctxapi.NewRootContext() + // Strict mode denies when the security context is incomplete, which is the + // behavior a guest sees by default. Relaxing it stands in for a grant. + ctx = securityapi.SetStrictMode(ctx, !permitted) + ctx, fc := ctxapi.OpenFrameContext(ctx) + t.Cleanup(func() { ctxapi.ReleaseFrameContext(fc) }) + if err := fc.SetMultiple(netapi.DefaultNetworkPair(networkID)); err != nil { + t.Fatalf("select overlay network: %v", err) + } + return ctx +} + +var errNotUsed = errors.New("not used by these tests") + +type stubRegistry struct { + svc netapi.Service + err error +} + +func (s stubRegistry) GetNetwork(registry.ID) (netapi.Service, error) { return s.svc, s.err } +func (s stubRegistry) HasNetwork(registry.ID) bool { return s.svc != nil } +func (s stubRegistry) NetworkKind(registry.ID) string { return "net.overlay" } + +type stubService struct { + dialed chan string +} + +// Only DialContext matters here; the rest of netapi.Service exists so the stub +// satisfies the interface the registry hands back. +func (s stubService) Listen(context.Context, string, string) (net.Listener, error) { + return nil, errNotUsed +} + +func (s stubService) ListenPacket(context.Context, string, string) (net.PacketConn, error) { + return nil, errNotUsed +} + +func (s stubService) LookupHost(context.Context, string) ([]string, error) { + return nil, errNotUsed +} + +func (s stubService) DialContext(_ context.Context, _, address string) (net.Conn, error) { + select { + case s.dialed <- address: + default: + } + client, server := net.Pipe() + go func() { _ = server.Close() }() + return client, nil +} + +// A guest must be routed by the network the application selected, exactly as +// host-side code is. Dialing clearnet instead would leak DNS and the target address. +func TestOverlayNetworkIsUsedWhenSelected(t *testing.T) { + dialed := make(chan string, 1) + ctx := netapi.WithNetworkRegistry( + overlayContext(t, "network:tor", true), stubRegistry{svc: stubService{dialed: dialed}}) + + host := New(nil) + conn, err := host.dial(ctx, "example.com", "example.com:443", 5*time.Second) + if err != nil { + t.Fatalf("dial through the overlay failed: %v", err) + } + defer conn.Close() + + select { + case address := <-dialed: + if address != "example.com:443" { + t.Errorf("overlay dialed %q, want example.com:443", address) + } + case <-time.After(time.Second): + t.Fatal("the overlay dialer was never called, so the guest bypassed it") + } +} + +// Falling back to clearnet when an overlay was asked for would defeat the overlay +// silently, which is worse than refusing. +func TestOverlayRequestedWithoutRegistryIsRefused(t *testing.T) { + ctx := overlayContext(t, "network:tor", true) + + host := New(nil) + conn, err := host.dial(ctx, "example.com", "example.com:443", time.Second) + if err == nil { + conn.Close() + t.Fatal("an overlay request with no registry must be refused, not dialed over clearnet") + } + t.Logf("refused: %v", err) +} + +func TestPrivateAddressNeedsPermission(t *testing.T) { + host := New(nil) + _, err := host.dial(context.Background(), "127.0.0.1", "127.0.0.1:9", time.Second) + if err == nil { + t.Fatal("a loopback address must require the private-IP permission") + } + t.Logf("refused: %v", err) +} + +// network.select is what gates overlay use for Lua, and it gates the guest the same +// way: without the permission the dial is refused rather than downgraded. +func TestOverlaySelectionNeedsPermission(t *testing.T) { + dialed := make(chan string, 1) + ctx := netapi.WithNetworkRegistry( + overlayContext(t, "network:tor", false), stubRegistry{svc: stubService{dialed: dialed}}) + + host := New(nil) + conn, err := host.dial(ctx, "example.com", "example.com:443", time.Second) + if err == nil { + conn.Close() + t.Fatal("network.select was not granted, so the dial must be refused") + } + if len(dialed) != 0 { + t.Fatal("the overlay was dialed despite the permission being denied") + } + t.Logf("refused: %v", err) +}