Skip to content

egress: add a SOCKS5 mode so the none lane can carry non-HTTP traffic #82

Description

@CMGS

Motivation

The none lane's only route out is the HTTP forward proxy: guest 127.0.0.1:3128 → one vsock connection per TCP connection → <vsock_socket>_2049egress.Proxy (pool/egress.go:armEgressProxy). Anything that is not absolute-form HTTP or CONNECT cannot leave the sandbox.

That is enough for HTTP-speaking clients. It is not enough for the case that motivated this: mounting external storage inside a vsock-only guest. A FUSE daemon in the guest (rclone mount, sshfs, juicefs) needs an arbitrary TCP destination — SFTP on 22, a metadata endpoint on a non-HTTP port, a database. Those clients speak SOCKS5 natively (rclone honours ALL_PROXY, ssh takes ProxyCommand), so a SOCKS5 mode turns the byte pipe we already have into a general egress path instead of inventing a protocol.

The transport is already in place. silkd's relay parses nothing — it splices raw bytes (silkd/src/net_egress.rs). The whole SOCKS5 handshake belongs host-side in sandboxd/egress.

Shape

A separate port, not protocol sniffing on the existing one:

  • sandboxd/engine: a second socket path beside EgressSocketPath (engine.go:429), e.g. <vsock_socket>_2050.
  • sandboxd/egress: a SOCKS5 server sharing Evaluator, DialFunc, the Event audit hook, and the hijacked-connection tracking Proxy.Close relies on.
  • silkd: one more net_egress::serve(1080, 2050) in main.rs. No new guest-side logic — the relay stays protocol-agnostic.

Sniffing the first byte on the existing listener would be less code but puts a new branch in front of every shipped HTTP request; a second listener keeps that path byte-identical.

Policy semantics — decide this before writing the server

A SOCKS5 request carries host:port and nothing else. Three of Rule's four fields cannot be enforced on it:

Rule field HTTP path SOCKS5
Host enforced enforceable
Methods enforced no method exists
Secret injected as a header no header to inject
Intercept terminates TLS, filters the inner request no HTTP inside

So SOCKS5 must not just call EvalHost. EvalHost deliberately prefers an intercept rule, and a plain match there may still be a rule whose entire point is Methods: [GET] or a Secret. Serving it over SOCKS5 would silently downgrade that rule to "any traffic to this host, no credential" — a guest could pick the SOCKS5 port to escape a restriction it cannot escape on 3128. That is a real bypass, not a theoretical one: both listeners are armed from the same policy for the same sandbox.

Proposal: add EvalStream(host string) (Rule, Decision) to Evaluator, matching only rules with no Methods, no Secret, and Intercept == false. A policy that wants a host reachable over SOCKS5 says so by writing a bare host rule; everything else denies. This leaves the HTTP path untouched and makes the weaker channel opt-in per rule rather than per sandbox. Compose intersects as it does today, deny wins.

Scope

  • Commands: CONNECT (0x01) only. BIND and UDP ASSOCIATE answer 0x07 (command not supported) — vsock is a stream transport, and UDP would be a policy blind spot.
  • Auth: no-auth (0x00). One listener carries one sandbox's identity, the same reason the HTTP proxy needs no in-band token. Adding user/password would introduce a secret with nothing to protect.
  • ATYP: DOMAINNAME resolved host-side (the guest has no DNS, which is the point); IPV4/IPV6 passed through. SSRF is already covered — newEgressDialer's Control hook checks the resolved address, so a domain resolving to an internal target is refused at dial time regardless of what the allow-list said.
  • Audit: every attempt emits an Event. The two channels do not enforce equal policy strength, so the record must say which one a request took; reusing a bare "CONNECT" would make them indistinguishable in the log.
  • Lifecycle: same armEgress/disarmEgress path, same default-deny (no policy → no listener → the guest's per-connection vsock dial is refused), same Close() teardown of live tunnels on release.

Hot path

armEgressProxy sits on the claim path. A second unconditional os.Remove + net.Listen + goroutine per claim is cost the volume-less warm claim does not pay today. Bind the SOCKS5 listener only when the effective policy holds at least one eligible rule, so a claim without one keeps its current syscall count. Target: no measurable change in warm claim p50 for pools that do not use it.

Open

Destination ports. Neither the HTTP CONNECT path nor this one constrains the destination port. SOCKS5 exists precisely for non-443 destinations, which makes a Rule.Ports field more valuable than it has been — but it is a wire and config change. Proposed: ship without it, file a follow-up.

Acceptance

  • Unit: an EvalStream table covering each of the three disqualifying fields; handshake tests for CONNECT, for a denied host, and for BIND/UDP ASSOCIATE rejection.
  • e2e on the testbed, none lane: a client reaching an allowed host through the guest's SOCKS5 port; refused for a host allowed only by a rule carrying Methods/Secret/Intercept; refused entirely with no policy armed.
  • A/B warm claim p50 on a pool with no SOCKS5 rule, judged against the A/A drift band.

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions