Standard library · Network

net.vless

The VLESS proxy protocol: client and server, TCP and UDP, vless:// share links, over TCP, TLS, WebSocket or any tunnel stream.

import net.vless

VLESS is a very small proxy protocol from the Xray and V2Ray family. A client opens a connection to the server and sends one header that says who it is (a UUID), what it wants (a TCP connection, or UDP datagrams) and where to (a host and a port). The server checks the UUID, answers with two bytes and from then on the connection carries the payload unchanged. That is all: VLESS does no encryption of its own. The protection has to come from what carries it, which is why it is used over TLS (transport.Settings.tls), usually inside a WebSocket behind a CDN.

This package is both halves. Dial connects a client through net.transport and sends the header; Accept reads the header on a server and checks it against a list of users. Read and Write then move the payload, ReadPacket and WritePacket move UDP datagrams, and ParseLink reads the vless:// share links that apps exchange. AsStream, DialOver and AcceptOver connect it to the net.tunnel streams, so it can be one hop of a chain or run inside another protocol. Importing this package pulls in net.transport, and you need that import yourself to build a Settings value and a target Addr.

What goes on the wire

DirectionBytes
Client to server, onceversion 0, the 16 byte user id, the length of the addon block (0, or the flow name wrapped in a small protobuf message), the command (1 TCP, 2 UDP, 3 mux), the port, the address kind, the address. The mux command has no address.
Server to client, onceversion 0 and the addon length (this package skips any addon).
Afterwards, TCPthe payload in both directions.
Afterwards, UDPeach datagram prefixed by its length in two bytes.

The client does not wait for the server's reply before it sends data. The two byte reply is read by the first Read, and a server that refuses the user simply closes the connection, so the client learns about it there and not from Dial.

What trips people up

  • Without TLS the user id is visible and nothing is encrypted. A plain TCP VLESS connection is a plain text tunnel with a password in front. Use TLS, or put VLESS inside something that encrypts.
  • A rejected client sees success. Dial only sends the header. A wrong UUID shows up as the first Read failing, on the server as unknown user.
  • The flow must match. A server user created with NewUser(id, "") accepts requests without a flow. A request that names a flow different from the user's is refused.
  • UDP is length prefixed datagrams on the one connection. It is the simple form; Xray's XUDP packet format is not implemented.
  • Close frees the request. Call Close once when you are done, on both sides; it also closes the transport underneath.

The request header

EncodeRequest and DecodeRequest are the header on its own, with no connection. They are what the tests compare with known bytes, and they are handy to see exactly what a server will receive. DecodeRequest returns how many bytes it consumed, or -1 when the bytes are cut short, have a wrong version or carry an unknown command.

net-vless-guide-header.salam
import net.transport
import net.vless

const ID := "b831381d-6324-4d53-ad4f-8cda48b30811"

func main:
    mut r := vless.Request {}
    r.uuid = transport.ParseUUID(ID)
    r.command = vless.CmdTCP
    r.addr = transport.NewAddr("example.com", 443)
    wire := vless.EncodeRequest(r)
    println transport.ToHex(wire)
    println wire.len, "bytes: version, 16 id bytes, 0 addon bytes, command, port, address type, length, name"

    mut back := vless.Request {}
    used := vless.DecodeRequest(wire, back)
    println used == wire.len, back.addr.host, back.addr.port, back.command, transport.FormatUUID(back.uuid) == ID

    cut := transport.FromHex("00b831381d63244d53ad4f8cda48b3081100" + "0101bb020b6578616d")
    println "a truncated header decodes to", vless.DecodeRequest(cut, back)
    odd := transport.FromHex("01b831381d63244d53ad4f8cda48b3081100" + "0101bb020b6578616d706c652e636f6d")
    println "a wrong version decodes to", vless.DecodeRequest(odd, back)
end
Output00b831381d63244d53ad4f8cda48b30811000101bb020b6578616d706c652e636f6d 34 bytes: version, 16 id bytes, 0 addon bytes, command, port, address type, length, name true example.com 443 1 true a truncated header decodes to -1 a wrong version decodes to -1

Apps share a server as one vless://uuid@host:port?query#name text. ParseLink reads it into a Link: check ok first, and read err when it is false. LinkSettings turns the link into the Settings for net.transport (the path's ?ed=2048 suffix becomes early_data), FormatLink goes back to text and DialLink does the whole connect. The parser refuses what the package cannot do, with a message that says why: security=reality, type=grpc, type=h2, type=xhttp, an encryption other than none, and a bad UUID.

net-vless-guide-link.salam
import net.vless

func main:
    text := "vless://b831381d-6324-4d53-ad4f-8cda48b30811@cdn.example.com:443?encryption=none&security=tls&sni=front.example.net&alpn=http%2F1.1&fp=chrome&type=ws&host=vpn.example.org&path=%2Fws-8f3a%3Fed%3D2048#My%20Server"
    l := vless.ParseLink(text)
    println l.ok, l.host, l.port, l.remark
    println l.security, l.network, l.sni, l.host_header, l.path

    s := vless.LinkSettings(l)
    println s.network, s.tls, s.sni, s.host, s.path, s.early_data

    again := vless.ParseLink(vless.FormatLink(l))
    println "round trip keeps the path:", again.path == l.path

    println vless.ParseLink("vless://b831381d-6324-4d53-ad4f-8cda48b30811@h:443?security=quic").err
    println vless.ParseLink("vless://b831381d-6324-4d53-ad4f-8cda48b30811@h:443?type=kcp").err
    println vless.ParseLink("vless://b831381d-6324-4d53-ad4f-8cda48b30811@h:443?encryption=aes").err
    println vless.ParseLink("vless://oops@h:443").err
end
Outputtrue cdn.example.com 443 My Server tls ws front.example.net vpn.example.org /ws-8f3a?ed=2048 ws true front.example.net vpn.example.org /ws-8f3a 2048 round trip keeps the path: true unsupported security quic unsupported transport kcp unsupported encryption aes bad uuid

Quick start: a client and a server over TCP

The server listens with transport.Listen, accepts with transport.Accept and hands the connection to vless.Accept with its list of users. The client dials with vless.Dial. The target address in the header is what the server would connect onward to; this echo server just prints it. In a real server you would dial that target (with net.tcp or a tunnel dialer) and copy bytes both ways.

net-vless-guide-loopback.salam
import str
import mem
import sync
import net.transport
import net.vless

const ID := "b831381d-6324-4d53-ad4f-8cda48b30811"

mut port := 0
mut ready := sync.NewWaitGroup()
mut seen := ""

func server():
    l := transport.Listen(0, transport.Settings {})
    port = transport.ListenerPort(l)
    sync.Done(ready)
    mut users := Vector {} as Vector<vless.User>
    users.push(vless.NewUser(ID, ""))
    mut c := vless.Accept(transport.Accept(l), users)
    if c.ok:
        seen = c.req.addr.host + ":" + str.FromInt(c.req.addr.port as i64)
        buf := mem.Allocate(4096 as u64)
        n := vless.Read(c, buf, 4096)
        vless.Write(c, buf, n)
        mem.Free(buf)
    else:
        seen = "refused: " + c.err
    end
    vless.Close(c)
    transport.CloseListener(l)
end

func main:
    sync.Add(ready, 1 as i64)
    t := spawn(server)
    sync.Wait(ready)

    target := transport.NewAddr("example.com", 443)
    mut c := vless.Dial("127.0.0.1", port, transport.Settings {}, ID, "", vless.CmdTCP, target)
    mut msg := transport.FromStr("hello through vless")
    vless.Write(c, msg.p, msg.len)
    buf := mem.Allocate(4096 as u64)
    n := vless.Read(c, buf, 4096)
    println (buf as str).substr(0, n)
    mem.Free(buf)
    transport.FreeBytes(msg)
    vless.Close(c)
    join(t)
    println "server saw the target", seen
end
Outputhello through vless server saw the target example.com:443

A user the server does not know

The client cannot tell by itself. The server sees unknown user, closes the connection and the client's first read fails.

net-vless-guide-wrong-user.salam
import sync
import net.transport
import net.vless

const GOOD := "b831381d-6324-4d53-ad4f-8cda48b30811"
const OTHER := "5783a3e7-e373-51cd-8642-c83782b807c5"

mut port := 0
mut ready := sync.NewWaitGroup()
mut verdict := ""

func server():
    l := transport.Listen(0, transport.Settings {})
    port = transport.ListenerPort(l)
    sync.Done(ready)
    mut users := Vector {} as Vector<vless.User>
    users.push(vless.NewUser(GOOD, ""))
    mut c := vless.Accept(transport.Accept(l), users)
    verdict = c.ok ? "accepted " + c.user : "refused: " + c.err
    vless.Close(c)
    transport.CloseListener(l)
end

func main:
    sync.Add(ready, 1 as i64)
    t := spawn(server)
    sync.Wait(ready)
    mut c := vless.Dial("127.0.0.1", port, transport.Settings {}, OTHER, "", vless.CmdTCP, transport.NewAddr("example.com", 80))
    println "client side looks fine until it reads:", c.ok
    join(t)
    println verdict
    vless.Close(c)
end
Outputclient side looks fine until it reads: true refused: unknown user

UDP datagrams

Dial with CmdUDP and the datagram target (a DNS server here). Each WritePacket sends one datagram and each ReadPacket returns one, whole, in the buffer; a datagram can be at most 65535 bytes. A zero length datagram reads as an empty one.

net-vless-guide-udp.salam
import str
import sync
import net.transport
import net.vless

const ID := "b831381d-6324-4d53-ad4f-8cda48b30811"

mut port := 0
mut ready := sync.NewWaitGroup()

func server():
    l := transport.Listen(0, transport.Settings {})
    port = transport.ListenerPort(l)
    sync.Done(ready)
    mut users := Vector {} as Vector<vless.User>
    users.push(vless.NewUser(ID, ""))
    mut c := vless.Accept(transport.Accept(l), users)
    until c.ok:
        mut p := transport.NewBytes(64)
        if not vless.ReadPacket(c, p):
            transport.FreeBytes(p)
            break
        end
        vless.WritePacket(c, p.p, p.len)
        transport.FreeBytes(p)
    end
    vless.Close(c)
    transport.CloseListener(l)
end

func main:
    sync.Add(ready, 1 as i64)
    t := spawn(server)
    sync.Wait(ready)
    dns := transport.NewAddr("8.8.8.8", 53)
    mut c := vless.Dial("127.0.0.1", port, transport.Settings {}, ID, "", vless.CmdUDP, dns)
    repeat 3 in i:
        mut msg := transport.FromStr("datagram " + str.FromInt(i as i64))
        vless.WritePacket(c, msg.p, msg.len)
        mut back := transport.NewBytes(64)
        vless.ReadPacket(c, back)
        println back.len, (back.p as str).substr(0, back.len)
        transport.FreeBytes(back)
        transport.FreeBytes(msg)
    end
    vless.Close(c)
    join(t)
end
Output10 datagram 0 10 datagram 1 10 datagram 2

Inside another stream

DialOver and AcceptOver run the protocol on any tunnel.Stream instead of a network connection, and AsStream turns the result back into a stream. This is how a chain is built: the output of one protocol is the carrier of the next. The example runs both ends over an in-memory pipe.

net-vless-guide-over-stream.salam
import mem
import sync
import net.transport
import net.tunnel
import net.vless

const ID := "b831381d-6324-4d53-ad4f-8cda48b30811"

mut pipe := tunnel.NewPipe()
mut done := sync.NewWaitGroup()
mut seen := ""

func server():
    mut users := Vector {} as Vector<vless.User>
    users.push(vless.NewUser(ID, ""))
    c := vless.AcceptOver(tunnel.PipeB(pipe), users)
    seen = c.req.addr.host
    s := vless.AsStream(c)
    buf := mem.Allocate(256 as u64)
    n := s.ReadBytes(buf, 256)
    s.WriteBytes(buf, n)
    mem.Free(buf)
    s.Close()
    sync.Done(done)
end

func main:
    sync.Add(done, 1 as i64)
    _t := spawn(server)
    c := vless.DialOver(tunnel.PipeA(pipe), ID, "", vless.CmdTCP, transport.NewAddr("inner.example", 443))
    s := vless.AsStream(c)
    s.Write("hello over a pipe")
    buf := mem.AllocateZeroed(256 as u64)
    n := s.ReadBytes(buf, 256)
    println (buf as str).substr(0, n)
    mem.Free(buf)
    s.Close()
    sync.Wait(done)
    println "server saw", seen
end
Outputhello over a pipe server saw inner.example

VLESS over WebSocket and TLS behind a CDN

The most common deployment. Do the CDN steps from the net.transport page: a proxied domain, a secret WebSocket path, TLS to the CDN with the front name as sni and your own domain as host. Then the share link carries all of it, and one call connects:

net-vless-guide-cdn.salam
import net.transport
import net.vless

const SHARE := "vless://b831381d-6324-4d53-ad4f-8cda48b30811@front.example.net:443?encryption=none&security=tls&sni=front.example.net&alpn=http%2F1.1&type=ws&host=vpn.example.org&path=%2Fws-8f3a#cdn"

func main:
    l := vless.ParseLink(SHARE)
    if not l.ok:
        println "bad link:", l.err
        ret
    end
    target := transport.NewAddr("example.com", 80)
    mut c := vless.DialLink(l, vless.CmdTCP, target)
    if not c.ok:
        println "could not connect:", c.err
        ret
    end
    mut req := transport.FromStr("HEAD / HTTP/1.0\r\nHost: example.com\r\n\r\n")
    vless.Write(c, req.p, req.len)
    transport.FreeBytes(req)
    vless.Close(c)
end

On the server, listen with network = "ws", the same path and host, with tls off when the CDN or your reverse proxy terminates TLS (and on, with a certificate and key, when it does not). Accept connections with transport.AcceptRaw, run transport.Upgrade and then vless.Accept in a worker thread for each of them.

Two things to know. First, VLESS has no encryption (encryption=none), so the CDN can read the traffic between its two TLS legs. For hostile CDNs use VMess with SecAES128GCM, which encrypts the payload itself. Second, set the fragment options of net.transport if the first hop is blocked by its TLS server name; they apply to this dial like any other.

Limits

  • Vision is the padding layer only. With the flow xtls-rprx-vision the first 8 writes in each direction are wrapped in Vision frames (user id, command, content and random padding, as Xray does), and the last of them tells the peer padding ended. Our client and server interoperate and the framing follows Xray; the "direct copy" step that skips the outer TLS after the inner TLS 1.3 handshake is not implemented, so the data stays inside the outer connection. A peer that sends the direct command is treated as ending its padding. Vision applies to CmdTCP only, and it has not been run against a real Xray server.
  • XHTTP and QUIC are not supported, and ParseLink refuses their links. The transports are tcp, ws, httpupgrade and grpc (serviceName and mode=gun|multi in the link), with none, tls or reality security (pbk, sid, spx, fp).
  • Multiplexing is Mux.Cool. Dial with CmdMux and call MuxClient; on the server check that Accept gave a request with CmdMux and call MuxServer. Sessions then come from net.transport (MuxOpen, MuxAccept).
  • The fingerprint of a link is informational. It is stored and written back, but the TLS handshake is not changed by it.
  • No live interoperability test against Xray or V2Ray was run. The header is checked against fixed byte vectors, and client and server are tested against each other over TCP, TLS, WebSocket and HTTP upgrade, but not against a real Xray build.
  • There are no server fallbacks. A connection that is not VLESS, or has an unknown user, is closed; it is not handed to a web server as Xray's fallbacks do.
  • Early data needs DialEarly. LinkSettings sets early_data from ?ed=, but Dial and DialLink do not send any early payload.

Types

Vision

struct Vision
The state of the Vision padding layer in both directions.
NameTypeDescription
uuidtransport.BytesThe user id.
writesint = 0How many padded frames this side has sent.
write_donebool = falseTrue once this side ended its padding.
uuid_sentbool = falseTrue after the first frame carried the user id.
read_donebool = falseTrue once the peer ended its padding.
read_rawbool = falseTrue when the peer does not speak Vision, so bytes pass unchanged.
read_initbool = trueTrue until the first frame has been checked against the user id.
head_leftint = 0Header bytes of the next frame still to read.
content_leftint = 0Content bytes of the current frame still to read.
pad_leftint = 0Padding bytes of the current frame still to skip.
commandint = 0The command of the frame being read.
switchedbool = falseTrue once reads were handed over to the raw socket after a direct command.
pendingtransport.BytesReceived bytes not yet unpadded.
readytransport.BytesUnpadded bytes waiting for the reader.

Request

struct Request
The request header of a connection: user, flow, command and target.
NameTypeDescription
uuidtransport.BytesThe 16 byte user id.
flowstr = ""The flow name, empty for none.
commandint = 1CmdTCP, CmdUDP or CmdMux.
addrtransport.AddrThe target address. Unused by CmdMux.

User

struct User
A user a server accepts.
NameTypeDescription
idstr = ""The user id as text.
uuidtransport.BytesThe user id as 16 bytes.
flowstr = ""The flow the user may name. Empty accepts only requests without one, and requests with an empty flow are always accepted.
struct Link
A parsed vless:// link.
NameTypeDescription
okbool = falseThe link was understood and uses only what this package supports.
errstr = ""Why the link was refused, empty when ok is true.
uuidstr = ""The user id text.
hoststr = ""The server host or IP address. IPv6 is written in brackets in the link.
portint = 443The server port. The default is 443.
remarkstr = ""The name after the #, percent decoded.
encryptionstr = "none"The encryption value. Only none is accepted.
flowstr = ""The flow value, empty when absent.
securitystr = "none"none, tls or reality.
snistr = ""The TLS server name. The peer key is read when sni is absent.
alpnstr = ""The ALPN value, percent decoded.
fingerprintstr = ""The fp value. It is stored and written back, and does not change the handshake.
networkstr = "tcp"tcp, ws, httpupgrade or grpc. Other values, such as h2, are refused.
host_headerstr = ""The host value, the Host header of the WebSocket request.
pathstr = ""The path value, percent decoded, with any ?ed= suffix still in it.
allow_insecurebool = falseTrue when the link says allowInsecure=1, insecure=1 or the word true.
realityreality.SettingsThe Reality parameters of the link (pbk, sid, spx, fp) when the security is reality.
service_namestr = ""The gRPC service name, from the serviceName query part.
grpc_modestr = "gun"gun or multi, from the mode query part.

Conn

struct Conn
A VLESS connection, client or server side.
NameTypeDescription
ttransport.ConnThe transport connection underneath.
clientbool = falseTrue on the client side.
okbool = falseTrue when the handshake step succeeded and the connection is usable.
errstr = ""The reason it failed, empty otherwise.
reqRequestThe request header: on a server what the client sent, on a client what it sent.
userstr = ""On a server: the id of the user that matched.
resp_pendingbool = falseInternal: the two byte server reply has not been read yet.
closedbool = falseInternal: Close has been called.
vis_onbool = falseTrue when the connection runs the Vision flow.
visVisionThe Vision padding state when the flow is Vision.

Constants and variables

FlowVision

const FlowVision: str = "xtls-rprx-vision"
The flow text xtls-rprx-vision.

VisionContinue

const VisionContinue: i32 = 0
The frame command meaning more padded frames follow.

VisionEnd

const VisionEnd: i32 = 1
The frame command meaning this is the last padded frame.

VisionDirect

const VisionDirect: i32 = 2
The frame command meaning the peer switches to direct copy. This package never sends it and treats it as the end of padding.

VisionPaddedPackets

const VisionPaddedPackets: i32 = 8
How many writes are padded before the end command: 8.

Commands Cmd…

The command byte of a request: CmdTCP opens a TCP connection to the target, CmdUDP carries UDP datagrams, CmdMux is the multiplexing command, which the header can carry but nothing implements.
NameValue
CmdTCP1
CmdUDP2
CmdMux3

Functions

AsStream

func AsStream(c: Conn): dyn tunnel.Stream
Wraps a connection as a tunnel.Stream so it can carry another protocol or be handed to tunnel code.

DialOver

func DialOver(s: dyn tunnel.Stream, uuid: str, flow: str, command: int, target: transport.Addr): Conn
Runs the client half over an existing stream instead of a new network connection.

AcceptOver

func AcceptOver(s: dyn tunnel.Stream, users: Vector<User>): Conn
Runs the server half over an existing stream.

MuxClient

func MuxClient(c: Conn): transport.Mux
Starts a Mux.Cool client multiplexer on a connection made with CmdMux.

MuxServer

func MuxServer(c: Conn): transport.Mux
Starts a Mux.Cool server demultiplexer on an accepted connection whose command is CmdMux.

NewVision

func NewVision(uuid: transport.Bytes): Vision
Makes the Vision state for a user id. Both ends need the same id.

FreeVision

func FreeVision(v &: Vision)
Releases the buffers of a Vision state.

VisionPad

func VisionPad(v &: Vision, p: void*, n: int, command: int, long_padding: bool): transport.Bytes
Builds one padded frame around a payload with the given command. The first frame of a direction starts with the user id.

VisionUnpad

func VisionUnpad(v &: Vision, p: void*, n: int, out &: transport.Bytes): bool
Feeds received bytes to the Vision state and appends the payload to out. False on a memory failure.

VisionWrite

func VisionWrite(t &: transport.Conn, v &: Vision, p: void*, n: int): bool
Writes through the Vision layer: the first 8 writes are padded, the rest pass unchanged.

VisionRead

func VisionRead(t &: transport.Conn, v &: Vision, buf: void*, max: int): int
Reads through the Vision layer: unpads while padding lasts, then returns raw bytes.

EncodeRequest

func EncodeRequest(r: Request): transport.Bytes
The request header as bytes. Free the result with transport.FreeBytes.

DecodeRequest

func DecodeRequest(b: transport.Bytes, r &: Request): int
Reads a request header from bytes and returns how many it used, or -1 when it is cut short, has a wrong version or an unknown command.

ReadRequest

func ReadRequest(t &: transport.Conn, r &: Request): bool
Reads one request header from a transport connection. False when it does not parse.

NewUser

func NewUser(uuid: str, flow: str): User
Makes a user from a UUID text and the flow it may use.

Client

func Client(t: transport.Conn, uuid: str, flow: str, command: int, addr: transport.Addr): Conn
Sends the request header on a transport connection that is already open and returns the VLESS connection. ok is false when the UUID is bad or the write fails.

Dial

func Dial(host: str, port: int, s: transport.Settings, uuid: str, flow: str, command: int, target: transport.Addr): Conn
Connects with transport.Dial and the given settings, then sends the header. The reply is not read yet: a refused user shows up on the first read.

Accept

func Accept(t: transport.Conn, users: Vector<User>): Conn
Reads the header on a transport connection, finds the user in the list and sends the reply. On failure ok is false, err says why (unknown user, bad request header, unsupported flow) and the transport is closed.

Read

func Read(c &: Conn, buf: void*, max: int): int
Reads payload. On a client the first call reads the two byte server reply. Returns 0 or less when the connection ended.

Write

func Write(c &: Conn, p: void*, n: int): bool
Writes payload from a pointer. False when it fails.

ReadPacket

func ReadPacket(c &: Conn, out &: transport.Bytes): bool
Reads one UDP datagram into the buffer, appending to it. False when the connection ended.

WritePacket

func WritePacket(c &: Conn, p: void*, n: int): bool
Writes one UDP datagram of at most 65535 bytes.

Close

func Close(c &: Conn)
Closes the connection and the transport under it. Safe to call twice.
func ParseLink(link: str): Link
Parses a vless:// link. Check ok; err says what was wrong or unsupported.
func FormatLink(l: Link): str
Writes a link back as text, percent encoding the values that need it.

LinkSettings

func LinkSettings(l: Link): transport.Settings
The transport.Settings that match a link: network, TLS, names, path and the early data size from ?ed=.
func DialLink(l: Link, command: int, target: transport.Addr): Conn
Dials the server of a link with its settings, user and flow and the command and target you give.