run: add --auto-publish for Lima-style host ← guest port mirroring

Polls /proc/net/tcp{,6} inside the guest every ~2s over the existing
agentd FsRead channel and diff-drives a per-guest-port host
listener. Each accepted host connection is bridged through a
per-connection in-guest python3 tunneller (exec over agentd) — no
upstream microsandbox changes needed, since the smoltcp PortPublisher
takes ports statically at boot and lives in the msb child process
behind a JSON-config boundary.

Mirrors Lima's default policy: only 0.0.0.0 / [::] binds are
auto-forwarded (loopback-only services stay private). When the
preferred host port is already taken, falls back to ephemeral.
Trade-off vs --publish: each inbound connection forks a python3
process — fine for dev tunnels, not for high-throughput.

Demo:
  agent-vm shell --auto-publish
  # in guest: python3 -m http.server 8080 --bind 0.0.0.0
  # on host: curl http://127.0.0.1:8080/  → reachable within ~2s

New modules:
  proc_net_tcp.rs   — /proc/net/tcp{,6} LISTEN parser (5 tests)
  exec_tunnel.rs    — per-connection python3 bridge
  auto_publish.rs   — discovery + diff + spawn/abort loop

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Evgeny Boger 2026-05-27 21:15:38 +00:00
parent 4407bc244d
commit 913d9e0066
5 changed files with 513 additions and 0 deletions

View file

@ -0,0 +1,154 @@
//! Lima-style auto-port-forwarding for agent-vm.
//!
//! Polls `/proc/net/tcp{,6}` inside the guest over the agentd
//! channel and diff-drives a per-guest-port host listener. The host
//! listener forwards each accepted connection through a
//! per-connection in-guest python3 tunneller (see [`crate::exec_tunnel`]).
//!
//! Mirrors Lima's default policy: only `0.0.0.0` / `[::]` binds are
//! auto-forwarded — loopback-only guest services stay private. To
//! reach a guest 127.0.0.1 service from the host, use `--publish`
//! (native, declarative) instead.
//!
//! Cancellation: the discovery task aborts itself when reading
//! `/proc/net/tcp` errors repeatedly (sandbox shutting down). On
//! ctrl-C the parent runtime drops the tokio handle and all spawned
//! listeners terminate naturally.
use std::collections::BTreeMap;
use std::net::{IpAddr, Ipv4Addr, SocketAddr};
use std::time::Duration;
use anyhow::Result;
use microsandbox::Sandbox;
use tokio::task::JoinHandle;
use crate::exec_tunnel;
use crate::proc_net_tcp::{ListenEntry, parse_listen_v4, parse_listen_v6};
/// Poll interval — matches Lima's default cadence and is short
/// enough that an HTTP server started inside the VM appears on the
/// host within a couple seconds.
const POLL_INTERVAL: Duration = Duration::from_millis(2000);
/// Max consecutive read errors before we give up — sandbox is
/// almost certainly shutting down. 5 × 2s = ~10s grace.
const MAX_CONSECUTIVE_ERRORS: u32 = 5;
/// Spawn the auto-port-forwarding background task.
pub fn spawn(sandbox: Sandbox) {
tokio::spawn(async move {
if let Err(e) = run(sandbox).await {
tracing::debug!(?e, "auto-publish loop exited");
}
});
}
async fn run(sandbox: Sandbox) -> Result<()> {
let mut active: BTreeMap<u16, ForwardedPort> = BTreeMap::new();
let mut consecutive_errors = 0u32;
loop {
tokio::time::sleep(POLL_INTERVAL).await;
let tcp4 = match sandbox.fs().read_to_string("/proc/net/tcp").await {
Ok(s) => s,
Err(e) => {
consecutive_errors += 1;
tracing::debug!(?e, errors = consecutive_errors, "read /proc/net/tcp failed");
if consecutive_errors >= MAX_CONSECUTIVE_ERRORS {
return Ok(());
}
continue;
}
};
let tcp6 = sandbox
.fs()
.read_to_string("/proc/net/tcp6")
.await
.unwrap_or_default();
consecutive_errors = 0;
let wanted: std::collections::BTreeSet<u16> = parse_listen_v4(&tcp4)
.into_iter()
.chain(parse_listen_v6(&tcp6))
.filter(|e| should_forward(*e))
.map(|e| e.port)
.collect();
// ADD: ports newly seen in wanted but not in active.
let new_ports: Vec<u16> = wanted
.iter()
.copied()
.filter(|p| !active.contains_key(p))
.collect();
for port in new_ports {
match bind_host_for(port).await {
Ok((host_port, listener)) => {
let task = exec_tunnel::spawn_listener(
listener,
sandbox.clone(),
"127.0.0.1".to_string(),
port,
);
eprintln!(
"==> auto-publish: guest :{port} → host 127.0.0.1:{host_port}"
);
active.insert(port, ForwardedPort { host_port, task });
}
Err(e) => {
tracing::warn!(guest_port = port, ?e, "failed to bind any host port");
}
}
}
// REMOVE: previously-active ports that disappeared from wanted.
let stale: Vec<u16> = active
.keys()
.copied()
.filter(|p| !wanted.contains(p))
.collect();
for port in stale {
if let Some(fp) = active.remove(&port) {
fp.task.abort();
eprintln!(
"==> auto-publish: guest :{port} closed (released host 127.0.0.1:{})",
fp.host_port
);
}
}
}
}
struct ForwardedPort {
host_port: u16,
task: JoinHandle<()>,
}
/// Decide whether to auto-forward this listener. Mirrors Lima's
/// default: only forward wildcard binds; loopback-only services
/// stay private to the guest.
fn should_forward(entry: ListenEntry) -> bool {
match entry.addr {
IpAddr::V4(a) => a.is_unspecified(),
IpAddr::V6(a) => a.is_unspecified(),
}
}
/// Try to bind `127.0.0.1:guest_port` first (so the host port
/// mirrors the guest port — Lima's behavior); if that's taken,
/// fall back to an OS-assigned ephemeral port.
async fn bind_host_for(guest_port: u16) -> Result<(u16, tokio::net::TcpListener)> {
let addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), guest_port);
if let Ok(l) = tokio::net::TcpListener::bind(addr).await {
let p = l.local_addr()?.port();
return Ok((p, l));
}
// Ephemeral.
let l = tokio::net::TcpListener::bind(SocketAddr::new(
IpAddr::V4(Ipv4Addr::LOCALHOST),
0,
))
.await?;
let p = l.local_addr()?.port();
Ok((p, l))
}

View file

@ -0,0 +1,179 @@
//! Host → guest port forwarding via per-connection in-guest tunnellers.
//!
//! Avoids touching microsandbox's `PortPublisher` (which would need a
//! runtime add/remove API and cross-process IPC into the `msb` child).
//! Instead, for each accepted host connection we `exec` a tiny python3
//! script inside the guest that bridges its stdin/stdout to a TCP
//! socket on the guest loopback, then pipe bytes between the host
//! TcpStream and the exec session's stdin/stdout over the existing
//! agentd channel.
//!
//! Trade-offs vs the native smoltcp `--publish` path:
//! - one python3 process per inbound connection (heavy: tens of ms
//! startup, ~10 MiB RSS each) — fine for dev tunnels, bad for
//! high-throughput traffic.
//! - bytes traverse: host TcpStream → agent.sock → agentd → python3
//! stdin → 127.0.0.1:guest_port. Extra hops vs smoltcp's direct
//! `inbound_relay → tcp::Socket` path.
//!
//! Upside: zero changes to the microsandbox SDK, and the guest's
//! 127.0.0.1 listener is reachable (smoltcp publish dials the guest
//! VLAN IP, so `127.0.0.1`-only services are unreachable that way).
use std::sync::Arc;
use anyhow::{Context, Result};
use microsandbox::Sandbox;
use microsandbox::sandbox::exec::{ExecEvent, ExecSink};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::{TcpListener, TcpStream};
use tokio::sync::Mutex;
/// Spawn the accept loop for a pre-bound listener. Each accepted
/// connection is bridged through a freshly-exec'd python3 tunneller
/// inside the guest.
///
/// Returns a `JoinHandle` for the listener loop so the auto-publish
/// discovery loop can `.abort()` it when the guest port disappears.
pub fn spawn_listener(
listener: TcpListener,
sandbox: Sandbox,
guest_host: String,
guest_port: u16,
) -> tokio::task::JoinHandle<()> {
tokio::spawn(async move {
loop {
let (stream, peer) = match listener.accept().await {
Ok(p) => p,
Err(e) => {
tracing::warn!(?e, "auto-publish accept failed");
continue;
}
};
let sb = sandbox.clone();
let g_host = guest_host.clone();
tokio::spawn(async move {
if let Err(e) = bridge_one(sb, stream, &g_host, guest_port).await {
tracing::debug!(?peer, ?e, "auto-publish bridge ended");
}
});
}
})
}
async fn bridge_one(
sandbox: Sandbox,
stream: TcpStream,
guest_host: &str,
guest_port: u16,
) -> Result<()> {
let script = build_tunnel_script(guest_host, guest_port);
let mut handle = sandbox
.exec_stream_with("python3", |e| {
e.args(["-u", "-c", script.as_str()]).stdin_pipe()
})
.await
.context("starting in-guest tunneller")?;
let stdin_sink = handle
.take_stdin()
.context("tunneller stdin pipe missing")?;
let stdin_sink = Arc::new(Mutex::new(stdin_sink));
let (mut host_rx, mut host_tx) = stream.into_split();
// host → guest: read TcpStream, push to python stdin
let sink_for_writer = stdin_sink.clone();
let host_to_guest = tokio::spawn(async move {
let mut buf = [0u8; 16384];
loop {
match host_rx.read(&mut buf).await {
Ok(0) | Err(_) => {
// half-close: tell python EOF so it shutdown(SHUT_WR)s
let _ = close_sink(&sink_for_writer).await;
return;
}
Ok(n) => {
let sink = sink_for_writer.lock().await;
if sink.write(&buf[..n]).await.is_err() {
return;
}
}
}
}
});
// guest → host: drain ExecEvent::Stdout, push to TcpStream
let mut exited = false;
while let Some(event) = handle.recv().await {
match event {
ExecEvent::Stdout(data) => {
if host_tx.write_all(&data).await.is_err() {
break;
}
}
ExecEvent::Exited { .. } | ExecEvent::Failed(_) => {
exited = true;
break;
}
ExecEvent::Stderr(data) => {
tracing::debug!(stderr = %String::from_utf8_lossy(&data), "tunneller stderr");
}
_ => {}
}
}
let _ = host_tx.shutdown().await;
host_to_guest.abort();
if !exited {
let _ = handle.kill().await;
}
Ok(())
}
async fn close_sink(sink: &Arc<Mutex<ExecSink>>) -> Result<(), microsandbox::MicrosandboxError> {
sink.lock().await.close().await
}
/// Python3 bidirectional bridge: stdin ↔ socket. Half-close aware so
/// HTTP/1.1 keep-alive clients (and other protocols that hold the
/// read half open after sending a request) don't deadlock.
fn build_tunnel_script(host: &str, port: u16) -> String {
format!(
r#"
import socket, sys, threading
s = socket.create_connection(("{host}", {port}))
def s2o():
try:
while True:
d = s.recv(16384)
if not d:
break
sys.stdout.buffer.write(d)
sys.stdout.buffer.flush()
finally:
try:
sys.stdout.buffer.flush()
except Exception:
pass
def i2s():
try:
while True:
d = sys.stdin.buffer.read1(16384)
if not d:
break
try:
s.sendall(d)
except Exception:
break
finally:
try:
s.shutdown(socket.SHUT_WR)
except Exception:
pass
t = threading.Thread(target=s2o, daemon=True)
t.start()
i2s()
t.join(timeout=5)
"#
)
}

View file

@ -1,12 +1,15 @@
//! agent-vm — sandboxed microVMs for AI coding agents on microsandbox.
mod auto_publish;
mod clipboard;
mod defaults;
mod exec_tunnel;
mod host_paths;
mod image_api_version;
mod image_check;
mod intercept_hook;
mod msb_install;
mod proc_net_tcp;
mod pull;
mod pull_progress;
mod pulled_marker;

View file

@ -0,0 +1,160 @@
//! Parse Linux `/proc/net/tcp{,6}` listen entries.
//!
//! Lines look like:
//!
//! ```text
//! sl local_address rem_address st tx_queue rx_queue tr tm->when retrnsmt uid timeout inode ...
//! 0: 0100007F:2382 00000000:0000 0A 00000000:00000000 00:00000000 00000000 0 0 1089 1 ...
//! ```
//!
//! - `local_address` is `HEX_IP:HEX_PORT` (each IPv4 byte
//! reversed within the 4-byte word — little-endian as written by
//! the kernel formatter).
//! - `st = 0A` is TCP_LISTEN.
//!
//! For `/proc/net/tcp6` the IP is 32 hex chars (16 bytes) in the
//! same per-word endianness convention.
use std::collections::BTreeSet;
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
/// One listening socket: bind address + port.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct ListenEntry {
pub addr: IpAddr,
pub port: u16,
}
/// Parse a single `/proc/net/tcp` body (skipping the header row).
/// Returns only LISTEN state entries.
pub fn parse_listen_v4(body: &str) -> BTreeSet<ListenEntry> {
let mut out = BTreeSet::new();
for line in body.lines().skip(1) {
if let Some(entry) = parse_v4_line(line) {
out.insert(entry);
}
}
out
}
pub fn parse_listen_v6(body: &str) -> BTreeSet<ListenEntry> {
let mut out = BTreeSet::new();
for line in body.lines().skip(1) {
if let Some(entry) = parse_v6_line(line) {
out.insert(entry);
}
}
out
}
fn parse_v4_line(line: &str) -> Option<ListenEntry> {
let fields: Vec<&str> = line.split_whitespace().collect();
// sl, local, remote, st, ...
if fields.len() < 4 || fields[3] != "0A" {
return None;
}
let (ip_hex, port_hex) = fields[1].split_once(':')?;
if ip_hex.len() != 8 {
return None;
}
let raw = u32::from_str_radix(ip_hex, 16).ok()?;
// kernel writes in native (little-endian on x86_64) byte order:
// 0100007F → bytes [01, 00, 00, 7F] → 127.0.0.1 read big-endian.
let bytes = raw.to_be_bytes();
let addr = IpAddr::V4(Ipv4Addr::new(bytes[3], bytes[2], bytes[1], bytes[0]));
let port = u16::from_str_radix(port_hex, 16).ok()?;
Some(ListenEntry { addr, port })
}
fn parse_v6_line(line: &str) -> Option<ListenEntry> {
let fields: Vec<&str> = line.split_whitespace().collect();
if fields.len() < 4 || fields[3] != "0A" {
return None;
}
let (ip_hex, port_hex) = fields[1].split_once(':')?;
if ip_hex.len() != 32 {
return None;
}
// The address is four little-endian u32 words concatenated.
// Convert each word to big-endian bytes so an all-zero v6
// address shows up as 0::0 and ::1 maps correctly.
let mut bytes = [0u8; 16];
for i in 0..4 {
let word = u32::from_str_radix(&ip_hex[i * 8..(i + 1) * 8], 16).ok()?;
let be = word.to_be_bytes();
bytes[i * 4] = be[3];
bytes[i * 4 + 1] = be[2];
bytes[i * 4 + 2] = be[1];
bytes[i * 4 + 3] = be[0];
}
let addr = IpAddr::V6(Ipv6Addr::from(bytes));
let port = u16::from_str_radix(port_hex, 16).ok()?;
Some(ListenEntry { addr, port })
}
#[cfg(test)]
mod tests {
use super::*;
use std::net::{Ipv4Addr, Ipv6Addr};
const SAMPLE_TCP4: &str = " sl local_address rem_address st\n\
0: 0100007F:2382 00000000:0000 0A 00000000:00000000 00:00000000 00000000 0 0 1089 1\n\
1: 00000000:1F90 00000000:0000 0A 00000000:00000000 00:00000000 00000000 0 0 42 1\n\
2: 0100007F:0050 0100007F:8000 01 00000000:00000000 00:00000000 00000000 0 0 99 1\n";
#[test]
fn parses_v4_loopback_and_wildcard() {
let s = parse_listen_v4(SAMPLE_TCP4);
assert!(s.contains(&ListenEntry {
addr: IpAddr::V4(Ipv4Addr::LOCALHOST),
port: 0x2382,
}));
assert!(s.contains(&ListenEntry {
addr: IpAddr::V4(Ipv4Addr::UNSPECIFIED),
port: 0x1F90,
}));
}
#[test]
fn skips_non_listen_states() {
let s = parse_listen_v4(SAMPLE_TCP4);
// st=01 (ESTABLISHED) for the third row must not appear.
assert!(!s.contains(&ListenEntry {
addr: IpAddr::V4(Ipv4Addr::LOCALHOST),
port: 0x0050,
}));
}
#[test]
fn parses_v6_unspecified() {
let sample = " sl local_address remote_address st\n\
0: 00000000000000000000000000000000:1F90 00000000000000000000000000000000:0000 0A 00000000:00000000 00:00000000 00000000 0 0 99 1\n";
let s = parse_listen_v6(sample);
assert!(s.contains(&ListenEntry {
addr: IpAddr::V6(Ipv6Addr::UNSPECIFIED),
port: 0x1F90,
}));
}
#[test]
fn parses_v6_loopback() {
// ::1 = 00000000000000000000000001000000 in kernel little-endian-per-u32 form
// (last 4 bytes are 01 00 00 00 in memory → ::1)
let sample = " sl local_address remote_address st\n\
0: 00000000000000000000000001000000:1F90 00000000000000000000000000000000:0000 0A 00000000:00000000 00:00000000 00000000 0 0 99 1\n";
let s = parse_listen_v6(sample);
assert!(
s.contains(&ListenEntry {
addr: IpAddr::V6(Ipv6Addr::LOCALHOST),
port: 0x1F90,
}),
"got {s:?}"
);
}
#[test]
fn empty_or_garbage_returns_empty_set() {
assert!(parse_listen_v4("").is_empty());
assert!(parse_listen_v4("garbage garbage garbage\n").is_empty());
}
}

View file

@ -141,6 +141,18 @@ pub struct Args {
#[arg(long = "publish", short = 'p')]
publish: Vec<String>,
/// Auto-detect new `0.0.0.0` / `[::]` TCP listeners inside the
/// guest and mirror each on `127.0.0.1:<same port>` on the host
/// (Lima-style). Polls `/proc/net/tcp{,6}` over the agentd
/// channel every ~2s; spawns a tokio TCP listener per detected
/// port and bridges each accepted connection through a
/// per-connection in-guest python3 tunneller. Loopback-only
/// guest binds (`127.0.0.1`) are intentionally NOT forwarded
/// (privacy / Lima parity). Heavy on overhead — fine for
/// dev tunnels, use `--publish` for high-throughput.
#[arg(long = "auto-publish", default_value_t = false)]
auto_publish: bool,
/// Override the OCI image reference. Default:
/// `ghcr.io/wirenboard/agent-vm-template:latest`. Use a timestamped tag
/// (`...:YYYY-MM-DDTHH`) to pin a reproducible image.
@ -669,6 +681,11 @@ pub async fn launch(agent: Agent, args: Args) -> Result<i32> {
.await
.context("verifying image-API contract version")?;
if args.auto_publish {
eprintln!("==> auto-publish: polling /proc/net/tcp every ~2s for new 0.0.0.0 listeners");
crate::auto_publish::spawn(sandbox.clone());
}
let inner_cmd = agent.command();
// Prepend agent-vm's default flags (e.g. --dangerously-skip-permissions
// for Claude) unless the user already provided them.