Help us shape the future of Go! Take the 2026 Go Developer Survey

wisp

package
v1.3.98 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Sep 30, 2026 License: AGPL-3.0 Imports: 17 Imported by: 0

Documentation

Overview

Package wisp pkg/wisp/client.go c4-app-proxy

The other end of server.go: a Wisp client, which opens a session against someone else's Wisp endpoint and multiplexes TCP and UDP streams over it — by URL with Dial, or over a conn you already have with DialConn. It is what lets a visor *consume* a Wisp backend rather than only provide one — the same protocol a browser-side Linux guest speaks, spoken outbound.

A Client is an Egress, so a Wisp server can be pointed at one and relay a guest's traffic through a second hop; and it is a proxy.ContextDialer, so anything in the tree that already takes a SOCKS5 dialer takes this instead.

One asymmetry with the server is worth knowing before reading the code: CONNECT is not acknowledged. Wisp has no "stream established" packet — a destination that cannot be reached produces a CLOSE some time later, which may be after the caller has already written. DialTCP therefore returns as soon as the CONNECT is queued, and an unreachable host surfaces as an error from the first Read or Write rather than from the dial. That is how every Wisp implementation behaves; it is a property of the protocol, not a shortcut taken here.

Package wisp pkg/wisp/client_ws.go c4-app-proxy

Dialing a Wisp endpoint by URL, off the browser.

HTTPClient and HTTPHeader are honored here and cannot be on js/wasm, which is why this has a js counterpart rather than a build-tagged struct field: the option belongs to the configuration everywhere, and only its availability differs.

Package wisp pkg/wisp/egress.go c4-app-proxy

Where a Wisp stream actually goes. CONNECT names its destination before a byte flows, so the choice is made per connection rather than per packet: the default is the local skysocks-client, which carries the stream over a route to an exit visor, and --direct dials the host's own network instead.

UDP goes through SOCKS5 UDP ASSOCIATE (socksudp.go), which skysocks now relays over the route to the exit, so a guest datagram reaches the same place its TCP does. A proxy without an association — an older exit, or some other CONNECT-only SOCKS5 — leaves port 53 working through a DNS-over-TCP translation, which spares each guest from running its own unbound with forward-tcp-upstream; every other port is refused rather than silently leaked to the clearnet.

Package wisp pkg/wisp/frames.go c4-app-proxy

The transport a Wisp session runs on.

Wisp is defined over a WebSocket, but nothing in a session needs one: it reads a frame, writes a frame, and closes. Naming that as an interface lets the same server and client run over anything that preserves message boundaries — which is what makes Wisp usable inside the wasm visor, where a WebSocket is not available in either direction:

  • websocket.Accept is //go:build !js, so a Wisp SERVER cannot exist on js/wasm at all;
  • a service worker cannot intercept ws:// or wss://, so a page-served endpoint could not be reached even if it could exist.

A virtual-loopback conn (bottle's vnet) has neither problem, so streamFrames below carries a session over any net.Conn by length-prefixing each frame.

Endianness: the prefix is little-endian, matching every multi-byte field in Wisp itself. The one big-endian length in this package is the DNS-over-TCP framing in egress.go, which is network order per RFC 1035 and is called out there for exactly this reason.

Package wisp pkg/wisp/packet.go c4-app-proxy

Wire format for the Wisp protocol (MercuryWorkshop), which multiplexes many TCP and UDP sockets over a single WebSocket. Browser-side Linux emulators use it as the egress path for a guest NIC: the page terminates the guest's TCP/IP itself and asks its Wisp backend to open connections by name, so the backend only ever sees "open a stream to host:port" and never a raw packet.

Every packet is a 5-byte header followed by a payload:

byte 0     packet type
bytes 1-4  stream ID, uint32 little-endian
bytes 5+   payload

Stream ID 0 is reserved for the handshake. All multi-byte fields in Wisp are little-endian; note that the DNS length prefix in egress.go is NOT, since that one is network order per RFC 1035.

Package wisp pkg/wisp/server.go c4-app-proxy

A Wisp server, speaking v1 and v2 over any transport (frames.go). On a WebSocket the version is the client's choice: v2 opens with a Sec-WebSocket-Protocol header and expects an INFO exchange first, v1 sends no such header and expects the initial CONTINUE straight away. On a raw conn there is no header to carry that, so ServeConn speaks v2.

Flow control is deliberately one-directional. Wisp's credit scheme only covers client -> server; the reverse direction rides on the transport's own backpressure, which is what the per-stream reader blocking on the session's write queue gives us. Adding a gate to the server -> client side stalls every transfer larger than one buffer and shows up as a truncated download at exactly the buffer boundary, so this end does not have one.

Package wisp pkg/wisp/server_http.go c4-app-proxy

Serving Wisp over a WebSocket, which is the transport the protocol is defined on and the one every browser Wisp client speaks.

It is //go:build !js because websocket.Accept is: coder/websocket's js/wasm build wraps the browser's own WebSocket object, which can dial but cannot listen. A visor in a tab therefore serves Wisp with Server.ServeConn over a virtual-loopback conn instead — see frames.go.

Package wisp pkg/wisp/socksserver.go c4-app-proxy

A SOCKS5 front end for a Wisp session: CONNECT becomes a Wisp TCP stream, UDP ASSOCIATE becomes one Wisp UDP stream per destination.

This is written here rather than taken from armon/go-socks5, which answers ASSOCIATE with "command not supported" and offers no hook to change that. Wrapping it does not work either: detecting ASSOCIATE means reading the request, which means answering the method-selection first, and the library would then answer it a second time on the same connection. Since a Wisp session now carries datagrams end to end, a proxy in front of it that cannot is the wrong half of the pair.

Names are never resolved here. The host from the request is passed to the backend as written, which is both what keeps the query off this machine and what makes the answer come from the network the traffic will actually use.

Package wisp pkg/wisp/socksudp.go c4-app-proxy

The client half of SOCKS5 UDP ASSOCIATE (RFC 1928 §7), which is what lets a guest's UDP stream cross a skywire exit now that skysocks relays datagrams.

golang.org/x/net/proxy speaks CONNECT only, so the association is done by hand here: greet the proxy, ask for an association, and send datagrams to the address it names, each one wrapped in the header that tells the relay where it is going. The TCP control connection is held open for the life of the association, because closing it is how RFC 1928 says an association ends — and how skysocks tears down the exit socket behind it.

Package wisp pkg/wisp/stream.go c4-app-proxy

One Wisp stream: the socket at the far end, the two pumps that move bytes across it, and the credit accounting that paces the client.

Index

Constants

View Source
const (
	PacketConnect  uint8 = 0x01
	PacketData     uint8 = 0x02
	PacketContinue uint8 = 0x03
	PacketClose    uint8 = 0x04
	PacketInfo     uint8 = 0x05
)

Packet types.

View Source
const (
	StreamTCP uint8 = 0x01
	StreamUDP uint8 = 0x02
)

Stream types carried in a CONNECT payload.

View Source
const (
	CloseUnspecified   uint8 = 0x01
	CloseVoluntary     uint8 = 0x02
	CloseNetworkError  uint8 = 0x03
	CloseIncompatExt   uint8 = 0x04
	CloseInvalidInfo   uint8 = 0x41
	CloseUnreachable   uint8 = 0x42
	CloseTimeout       uint8 = 0x43
	CloseRefused       uint8 = 0x44
	CloseTransferTimeo uint8 = 0x47
	CloseBlocked       uint8 = 0x48
	CloseThrottled     uint8 = 0x49
)

Close reasons. The 0x4x range is server-only, 0x8x client-only.

View Source
const (
	ExtUDP        uint8 = 0x01
	ExtPassword   uint8 = 0x02
	ExtPublicKey  uint8 = 0x03
	ExtMOTD       uint8 = 0x04
	ExtStreamOpen uint8 = 0x05
)

Extension IDs advertised in an INFO packet.

View Source
const (
	// DefaultBuffer is the per-stream client -> server credit, in packets.
	DefaultBuffer uint32 = 128
	// DefaultReadLimit bounds one frame. A Wisp DATA payload is
	// whatever the client's TCP stack handed over, so this only needs to
	// clear a jumbo frame with room to spare.
	DefaultReadLimit int64 = 1 << 20
)
View Source
const HeaderLen = 5

HeaderLen is the fixed packet header size: type + stream ID.

View Source
const Subprotocol = "wisp-v2"

Subprotocol is the value sent in Sec-WebSocket-Protocol to ask for Wisp v2. The spec keys the version off the header being present rather than off any particular name, but the reference client sends this literal and some backends match on it.

Variables

View Source
var ErrClientClosed = errors.New("wisp: client session closed")

ErrClientClosed is returned once a Client's session has ended.

View Source
var ErrShortPacket = errors.New("wisp: packet shorter than header")

ErrShortPacket is returned when a frame is too small to be a Wisp packet.

View Source
var ErrUDPUnsupported = errors.New("wisp: UDP is not carried by this egress")

ErrUDPUnsupported is returned by an Egress that cannot carry a UDP stream to the requested port.

Functions

func Encode

func Encode(typ uint8, streamID uint32, payload []byte) []byte

Encode builds a frame from a type, stream ID and payload.

func EncodeClose

func EncodeClose(streamID uint32, reason uint8) []byte

EncodeClose builds a CLOSE frame carrying a reason code.

func EncodeConnect

func EncodeConnect(streamID uint32, streamType uint8, host string, port uint16) []byte

EncodeConnect builds a CONNECT frame. Used by the tests and by any client implementation built on this package.

func EncodeContinue

func EncodeContinue(streamID, buffer uint32) []byte

EncodeContinue builds a CONTINUE frame carrying a buffer credit, in packets.

func EncodeData

func EncodeData(streamID uint32, payload []byte) []byte

EncodeData builds a DATA frame.

func EncodeInfo

func EncodeInfo(info Info) []byte

EncodeInfo builds an INFO frame. INFO always rides on stream 0.

func ParseContinue

func ParseContinue(payload []byte) (uint32, error)

ParseContinue decodes a CONTINUE payload into the buffer size it carries, in packets. The value is the remaining buffer, not a delta.

Types

type Client

type Client struct {
	// contains filtered or unexported fields
}

Client is a Wisp client session: one transport carrying many streams.

func Dial

func Dial(ctx context.Context, cfg ClientConfig) (*Client, error)

Dial opens a Wisp session against cfg.URL. The returned Client owns a goroutine pair until Close.

func DialConn

func DialConn(ctx context.Context, conn net.Conn, cfg ClientConfig) (*Client, error)

DialConn opens a Wisp session over a byte stream that is already connected, framing it rather than upgrading it (frames.go). It is the counterpart of Server.ServeConn, and the path that works on js/wasm: the browser WebSocket cannot carry a custom transport or custom headers, so a session that has to ride one (a virtual-loopback conn, a skywire stream, a SOCKS5 proxy) is built here instead of dialed by URL.

The session speaks v2, matching ServeConn.

DialConn owns conn and closes it with the session.

func (*Client) Buffer

func (c *Client) Buffer() uint32

Buffer returns the per-stream credit the server granted.

func (*Client) Close

func (c *Client) Close() error

Close ends the session and every stream on it.

func (*Client) Describe

func (c *Client) Describe() string

Describe implements Egress.

func (*Client) Dial

func (c *Client) Dial(network, address string) (net.Conn, error)

Dial implements proxy.Dialer.

func (*Client) DialContext

func (c *Client) DialContext(ctx context.Context, network, address string) (net.Conn, error)

DialContext implements proxy.ContextDialer, so a Client can stand in for a SOCKS5 dialer anywhere one is already accepted. Only tcp networks are carried; a udp address has no stream semantics to offer io.ReadWriteCloser callers and belongs on DialUDP.

func (*Client) DialTCP

func (c *Client) DialTCP(_ context.Context, host string, port uint16) (net.Conn, error)

DialTCP implements Egress. See the package comment on why this returns before the far end is known to be reachable.

func (*Client) DialUDP

func (c *Client) DialUDP(_ context.Context, host string, port uint16) (DatagramStream, error)

DialUDP implements Egress.

func (*Client) Done

func (c *Client) Done() <-chan struct{}

Done returns a channel closed when the session ends.

func (*Client) Err

func (c *Client) Err() error

Err returns why the session ended, or nil while it is live.

func (*Client) UDPSupported

func (c *Client) UDPSupported() bool

UDPSupported reports whether the server advertised the UDP extension. A v1 server never advertises anything, so this is false there even though a v1 server may in fact carry UDP streams.

func (*Client) Version

func (c *Client) Version() int

Version reports the negotiated protocol version, 1 or 2.

type ClientConfig

type ClientConfig struct {
	// URL is the Wisp endpoint, ws:// or wss://. Required.
	URL string
	// Header carries extra headers on the WebSocket handshake.
	Header http.Header
	// HTTPClient performs the handshake. Zero means http.DefaultClient.
	// Set this to route the WebSocket itself over something — a SOCKS5
	// proxy, a skywire route — rather than the host's network.
	HTTPClient *http.Client
	// ReadLimit bounds one frame. Zero means DefaultReadLimit.
	ReadLimit int64
	// Log receives per-session and per-stream events. Zero means a logger
	// named "wisp-client".
	Log *logging.Logger
}

ClientConfig configures a Client.

type CloseError

type CloseError uint8

CloseError is a CLOSE reason code presented as an error, so that a stream torn down by the far end reports why through the ordinary error path.

func (CloseError) Error

func (e CloseError) Error() string

Error implements error.

type Config

type Config struct {
	// Egress opens the far end of every stream. Required.
	Egress Egress
	// Buffer is the per-stream client -> server credit in packets.
	// Zero means DefaultBuffer.
	Buffer uint32
	// DialTimeout bounds one CONNECT. Zero means defaultDialTimeout.
	DialTimeout time.Duration
	// ReadLimit bounds one frame. Zero means DefaultReadLimit.
	ReadLimit int64
	// Log receives per-session and per-stream events. Zero means a logger
	// named "wisp".
	Log *logging.Logger
}

Config configures a Server.

type ConnectPayload

type ConnectPayload struct {
	StreamType uint8
	Port       uint16
	Host       string
}

ConnectPayload is a decoded CONNECT payload.

func ParseConnect

func ParseConnect(payload []byte) (ConnectPayload, error)

ParseConnect decodes a CONNECT payload (the bytes after the packet header).

Both v1 and v2 carry the stream type here; the difference between them is that v2 advertises UDP support up front in INFO, so a v2 client knows not to ask for it when the server did not offer it.

type DatagramStream

type DatagramStream interface {
	WriteDatagram(b []byte) error
	ReadDatagram() ([]byte, error)
	Close() error
}

DatagramStream is a message-oriented stream: one Write is one datagram, one Read is one datagram. It is what a Wisp UDP stream needs underneath, and it is deliberately not a net.Conn, because the DNS-over-TCP translation is not one.

type DirectEgress

type DirectEgress struct {
	Dialer net.Dialer
}

DirectEgress dials the host's own network. Nothing is carried over skywire.

func (*DirectEgress) Describe

func (e *DirectEgress) Describe() string

Describe implements Egress.

func (*DirectEgress) DialTCP

func (e *DirectEgress) DialTCP(ctx context.Context, host string, port uint16) (net.Conn, error)

DialTCP implements Egress.

func (*DirectEgress) DialUDP

func (e *DirectEgress) DialUDP(ctx context.Context, host string, port uint16) (DatagramStream, error)

DialUDP implements Egress.

type Egress

type Egress interface {
	DialTCP(ctx context.Context, host string, port uint16) (net.Conn, error)
	DialUDP(ctx context.Context, host string, port uint16) (DatagramStream, error)
	// Describe names the egress for logs and for the command's banner.
	Describe() string
}

Egress opens the far end of a Wisp stream.

type Extension

type Extension struct {
	ID       uint8
	Metadata []byte
}

Extension is one entry of an INFO packet's extension list.

type Frames

type Frames interface {
	// ReadFrame returns the next frame. The slice is the caller's.
	ReadFrame(ctx context.Context) ([]byte, error)
	// WriteFrame sends one frame.
	WriteFrame(ctx context.Context, b []byte) error
	// Close releases the transport. It is safe to call more than once.
	Close() error
}

Frames is a message transport: one Write is one frame, one Read is one frame. It is what a Wisp session actually requires of its socket.

func NewStreamFrames

func NewStreamFrames(conn net.Conn, readLimit int64) Frames

NewStreamFrames adapts a byte stream to Frames. readLimit bounds one frame; zero means DefaultReadLimit.

func NewWebsocketFrames

func NewWebsocketFrames(conn *websocket.Conn) Frames

NewWebsocketFrames adapts a WebSocket to Frames. The conn's read limit should already be set by the caller.

type Info

type Info struct {
	Major      uint8
	Minor      uint8
	Extensions []Extension
}

Info is a decoded INFO packet payload.

func ParseInfo

func ParseInfo(payload []byte) (Info, error)

ParseInfo decodes an INFO payload (the bytes after the packet header).

func (Info) HasExtension

func (i Info) HasExtension(id uint8) bool

HasExtension reports whether the INFO advertises the given extension ID.

type Packet

type Packet struct {
	Type     uint8
	StreamID uint32
	Payload  []byte
}

Packet is a decoded Wisp frame. Payload aliases the caller's buffer.

func Parse

func Parse(b []byte) (Packet, error)

Parse decodes a Wisp frame. The returned Payload aliases b.

type Server

type Server struct {
	// contains filtered or unexported fields
}

Server serves the Wisp protocol. Off the browser it implements http.Handler, so it can be mounted on any path and upgraded to a WebSocket; everywhere, including js/wasm, ServeConn runs a session over a plain byte stream.

func NewServer

func NewServer(cfg Config) (*Server, error)

NewServer builds a Server. It returns an error when no egress is configured, since a Wisp server with nowhere to dial is never what the caller meant.

func (*Server) ServeConn

func (s *Server) ServeConn(ctx context.Context, conn net.Conn)

ServeConn runs one Wisp session over a byte stream, framing it rather than upgrading it. This is the path that works on js/wasm, where websocket.Accept does not exist and a service worker could not reach it anyway: a visor in a tab serves Wisp on a virtual-loopback port and the page connects to that.

The session speaks v2. Both ends of a raw conn are chosen by whoever wired them together, so there is no version to negotiate and no header to carry one in.

ServeConn owns conn and closes it before returning.

func (*Server) ServeFrames

func (s *Server) ServeFrames(ctx context.Context, frames Frames, v2 bool)

ServeFrames runs one Wisp session over any transport. v2 selects the INFO exchange; false starts with the v1 CONTINUE instead. A v2 session whose client ignores the INFO and opens a stream falls back to v1 rather than refusing it, so a framed transport — which has no handshake to negotiate with — serves v1 and v2 clients alike.

It owns frames and closes them before returning.

func (*Server) ServeHTTP

func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request)

ServeHTTP upgrades the request and runs one Wisp session on it.

type SocksEgress

type SocksEgress struct {
	Addr string
	// contains filtered or unexported fields
}

SocksEgress carries streams through a SOCKS5 proxy — by default the local skysocks-client, which puts them on a route to an exit visor.

func NewSocksEgress

func NewSocksEgress(addr string) (*SocksEgress, error)

NewSocksEgress builds an egress over the SOCKS5 proxy at addr, reached over the host's own network.

func NewSocksEgressThrough

func NewSocksEgressThrough(addr string, forward proxy.Dialer) (*SocksEgress, error)

NewSocksEgressThrough builds an egress over the SOCKS5 proxy at addr, reaching the proxy itself through forward.

This matters in a tab. The visor's skysocks-client binds its SOCKS5 port on the page's virtual loopback, which net.Dial knows nothing about — Go's js runtime simulates a per-instance loopback of its own, so a direct dial finds nothing there. Passing bottle's vnet as forward is what lets an in-page Wisp server reach the in-page proxy.

func (*SocksEgress) Describe

func (e *SocksEgress) Describe() string

Describe implements Egress.

func (*SocksEgress) DialTCP

func (e *SocksEgress) DialTCP(ctx context.Context, host string, port uint16) (net.Conn, error)

DialTCP implements Egress.

func (*SocksEgress) DialUDP

func (e *SocksEgress) DialUDP(ctx context.Context, host string, port uint16) (DatagramStream, error)

DialUDP implements Egress.

UDP ASSOCIATE is tried first: skysocks relays datagrams over the route to the exit, so a guest's UDP reaches the same place its TCP does.

Port 53 falls back to the DNS-over-TCP translation whenever the association cannot be had, which is what spares each guest from running its own unbound. The fallback is on ANY failure rather than only on a refusal, because there is a second way to have no association: inside a tab there are no UDP sockets at all, so the association fails on its own socket rather than on the proxy's answer. Treating only a refusal as grounds to fall back left in-page DNS broken for a reason that had nothing to do with the proxy.

Any other port is refused rather than quietly sent out of the host's own interface, which would put traffic on the clearnet the caller asked to route over skywire.

type SocksServer

type SocksServer struct {
	// Session returns the session to carry streams over. It is called per
	// request rather than held, so a caller can redial a dead backend
	// without this server knowing anything about it. Required.
	Session func(ctx context.Context) (*Client, error)
	// Log receives per-connection events. Zero means a logger named
	// "wisp-socks".
	Log *logging.Logger
}

SocksServer serves SOCKS5 over a Wisp session.

func (*SocksServer) Serve

func (s *SocksServer) Serve(l net.Listener) error

Serve accepts connections until l fails.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL