Skip to content

feat: add --publish-socket for exposing a guest TCP port as a host unix socket#2

Open
luke wants to merge 1 commit into
mainfrom
socket-publish
Open

feat: add --publish-socket for exposing a guest TCP port as a host unix socket#2
luke wants to merge 1 commit into
mainfrom
socket-publish

Conversation

@luke

@luke luke commented Jul 16, 2026

Copy link
Copy Markdown

What

--publish-socket HOST_PATH:GUEST_PORT on machine create makes a TCP port the
guest listens on reachable on the host as a Unix socket file — -p without the
host TCP listener. It completes the socket-bridge triple from smol-machines#656: expose
(guest unix socket → host), mount (host unix socket → guest), publish (guest
TCP port → host unix socket).

smolvm machine create --name api --publish-socket /tmp/api.sock:8080
smolvm machine start --name api
smolvm machine exec --name api -- ./serve --port 8080 &
curl --unix-socket /tmp/api.sock http://localhost/health

Why

Tools that embed smolvm as an engine want the only host-side reachability of
a guest service to be a socket file the caller owns (the colima
DOCKER_HOST=unix:// shape): no port collisions, no loopback exposure to other
local processes, file-permission access control for free. Guest workloads
overwhelmingly listen on TCP, so the unix↔unix bridges from smol-machines#656 don't reach
them without a shim inside the guest.

How

Publish rides the smol-machines#656 machinery end to end. A new SocketDirection::Publish
is host-side identical to Expose — dynamic vsock port, libkrun listens on the
host socket, stale file replaced at start, same 64-entry cap — and the guest
agent's serve_publish mirrors serve_expose, except each host connection
dials the guest port on loopback over TCP with a short connect timeout, so a
dial the guest firewall silently drops cannot pin the forwarder thread (a
refused connection still fails immediately). The shared relay is generic over the
stream type, so independent half-close carries across the TCP leg unchanged (a
FIN maps to shutdown(SHUT_WR)); a port with no listener behaves like an
expose target whose socket is missing — the connection is dropped and the host
client sees a reset, no start-order coupling. The launcher pipeline and
BootConfig carry no functional changes — doc comments updated only, no new
fields — so the cfg(not(unix)) initializers from the smol-machines#656 follow-up are
unaffected. The wire encoding extends naturally to port|publish|8080 with
parse-time and decode-time validation.

CLI grammar is host-first, matching -p HOST:GUEST and --mount-socket; the
spec splits on the last colon so host paths containing : keep working.

The AGENTS.md section added here also documents the two existing smol-machines#656 flags
(--expose-socket / --mount-socket), which were previously undocumented —
included so all three bridges are described in one place.

Tests

  • protocol: publish round-trips the wire encoding; malformed publish targets
    (path, port 0, >65535) are skipped like other malformed entries
  • agent: relay over a real unix↔TCP pair delivers a 256 KiB tail after a
    client half-close (plus the existing three relay tests)
  • CLI: valid/invalid spec parsing, including a host path containing :
  • integration (tests/test_ports.sh): HTTP round-trip through the published
    socket with an ss check that no process holds a host TCP listener on the
    published port; stale socket file replaced at start; no-listener fails fast
    and recovers once a listener appears
  • E2E on Linux/KVM: curl round-trip through the published socket against a
    real guest HTTP server, 20 concurrent requests, half-close probes in both
    directions with byte-count asserts, ss proof that no process listens on
    the published ports, no-listener fail-fast plus recovery, and stop/start
    lifecycle — 13/13 passing
  • E2E on macOS/Hypervisor.framework (Apple Silicon): same 13-assert suite, 13/13 passing (no-ss platform adaptations noted in the transcript)

Format/clippy/unit gates green on Linux; Windows cross-compile checked.

Possible follow-up

Three pieces of shared smol-machines#656 semantics are deliberately left out of this PR
and would make a natural follow-up hardening PR, since each touches the
existing expose/mount behavior too: an explicit ,mode= option (or tighter
default) for the host socket file, which today is created with the invoking
user's umask and not chmod'd, leaving access control to the host path's
directory permissions; surfacing a host-socket bind failure
(krun_add_vsock_port2 returning an error) as a hard launch error instead of
a warn-and-disable; and rejecting duplicate host paths across bridge
definitions, where today the last bind quietly wins.

…ix socket

Completes the socket-bridge triple from smol-machines#656: expose (guest unix socket ->
host), mount (host unix socket -> guest), and now publish (guest TCP port ->
host unix socket) — -p without the host TCP listener, so a guest service like
an engine API is reachable on the host only through a socket file the caller
owns (the colima DOCKER_HOST shape).

Publish rides the smol-machines#656 machinery end to end: a new SocketDirection::Publish is
host-side identical to Expose (dynamic vsock port, libkrun listens on the host
socket, stale file replaced at start), and the guest agent's serve_publish
mirrors serve_expose except each host connection dials the guest port on
loopback over TCP, with a short connect timeout so a dial the guest firewall
silently drops cannot pin the forwarder thread. The shared relay already
carries independent half-close generically, and a FIN maps to a TCP shutdown,
so streaming protocols keep their tail bytes in both directions; a connection
to a port with no listener is dropped exactly like an expose target whose
socket is missing — no start-order coupling. There are no functional launcher
changes (doc comments only), and no BootConfig fields are added, so the
cfg(not(unix)) initializers stay untouched.

CLI grammar is --publish-socket HOST_PATH:GUEST_PORT (host first, matching -p
and --mount-socket); the port is validated at parse time and split on the last
colon so host paths containing ':' keep working. Unit tests cover the protocol
round-trip and malformed publish targets, the unix<->TCP relay with half-close,
and the CLI parser; test_ports.sh gains an HTTP round-trip through a published
socket (including a port-based no-host-TCP-listener check) and a
no-listener/recovery case.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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