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
| Direction | Bytes |
|---|---|
| Client to server, once | version 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, once | version 0 and the addon length (this package skips any addon). |
| Afterwards, TCP | the payload in both directions. |
| Afterwards, UDP | each 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.
Dialonly sends the header. A wrong UUID shows up as the firstReadfailing, on the server asunknown 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
Closeonce 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.
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)
endShare links
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.
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
endQuick 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.
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
endA 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.
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)
endUDP 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.
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)
endInside 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.
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
endVLESS 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:
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)
endOn 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-visionthe 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 toCmdTCPonly, and it has not been run against a real Xray server. - XHTTP and QUIC are not supported, and
ParseLinkrefuses their links. The transports aretcp,ws,httpupgradeandgrpc(serviceNameandmode=gun|multiin the link), withnone,tlsorrealitysecurity (pbk,sid,spx,fp). - Multiplexing is Mux.Cool. Dial with
CmdMuxand callMuxClient; on the server check thatAcceptgave a request withCmdMuxand callMuxServer. Sessions then come fromnet.transport(MuxOpen,MuxAccept). - The
fingerprintof 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.LinkSettingssetsearly_datafrom?ed=, butDialandDialLinkdo not send any early payload.
Types
Vision
struct Vision| Name | Type | Description |
|---|---|---|
uuid | transport.Bytes | The user id. |
writes | int = 0 | How many padded frames this side has sent. |
write_done | bool = false | True once this side ended its padding. |
uuid_sent | bool = false | True after the first frame carried the user id. |
read_done | bool = false | True once the peer ended its padding. |
read_raw | bool = false | True when the peer does not speak Vision, so bytes pass unchanged. |
read_init | bool = true | True until the first frame has been checked against the user id. |
head_left | int = 0 | Header bytes of the next frame still to read. |
content_left | int = 0 | Content bytes of the current frame still to read. |
pad_left | int = 0 | Padding bytes of the current frame still to skip. |
command | int = 0 | The command of the frame being read. |
switched | bool = false | True once reads were handed over to the raw socket after a direct command. |
pending | transport.Bytes | Received bytes not yet unpadded. |
ready | transport.Bytes | Unpadded bytes waiting for the reader. |
Request
struct Request| Name | Type | Description |
|---|---|---|
uuid | transport.Bytes | The 16 byte user id. |
flow | str = "" | The flow name, empty for none. |
command | int = 1 | CmdTCP, CmdUDP or CmdMux. |
addr | transport.Addr | The target address. Unused by CmdMux. |
User
struct User| Name | Type | Description |
|---|---|---|
id | str = "" | The user id as text. |
uuid | transport.Bytes | The user id as 16 bytes. |
flow | str = "" | The flow the user may name. Empty accepts only requests without one, and requests with an empty flow are always accepted. |
Link
struct Linkvless:// link.| Name | Type | Description |
|---|---|---|
ok | bool = false | The link was understood and uses only what this package supports. |
err | str = "" | Why the link was refused, empty when ok is true. |
uuid | str = "" | The user id text. |
host | str = "" | The server host or IP address. IPv6 is written in brackets in the link. |
port | int = 443 | The server port. The default is 443. |
remark | str = "" | The name after the #, percent decoded. |
encryption | str = "none" | The encryption value. Only none is accepted. |
flow | str = "" | The flow value, empty when absent. |
security | str = "none" | none, tls or reality. |
sni | str = "" | The TLS server name. The peer key is read when sni is absent. |
alpn | str = "" | The ALPN value, percent decoded. |
fingerprint | str = "" | The fp value. It is stored and written back, and does not change the handshake. |
network | str = "tcp" | tcp, ws, httpupgrade or grpc. Other values, such as h2, are refused. |
host_header | str = "" | The host value, the Host header of the WebSocket request. |
path | str = "" | The path value, percent decoded, with any ?ed= suffix still in it. |
allow_insecure | bool = false | True when the link says allowInsecure=1, insecure=1 or the word true. |
reality | reality.Settings | The Reality parameters of the link (pbk, sid, spx, fp) when the security is reality. |
service_name | str = "" | The gRPC service name, from the serviceName query part. |
grpc_mode | str = "gun" | gun or multi, from the mode query part. |
Conn
struct Conn| Name | Type | Description |
|---|---|---|
t | transport.Conn | The transport connection underneath. |
client | bool = false | True on the client side. |
ok | bool = false | True when the handshake step succeeded and the connection is usable. |
err | str = "" | The reason it failed, empty otherwise. |
req | Request | The request header: on a server what the client sent, on a client what it sent. |
user | str = "" | On a server: the id of the user that matched. |
resp_pending | bool = false | Internal: the two byte server reply has not been read yet. |
closed | bool = false | Internal: Close has been called. |
vis_on | bool = false | True when the connection runs the Vision flow. |
vis | Vision | The Vision padding state when the flow is Vision. |
Constants and variables
FlowVision
const FlowVision: str = "xtls-rprx-vision"xtls-rprx-vision.VisionContinue
const VisionContinue: i32 = 0VisionEnd
const VisionEnd: i32 = 1VisionDirect
const VisionDirect: i32 = 2VisionPaddedPackets
const VisionPaddedPackets: i32 = 8Commands Cmd…
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.| Name | Value |
|---|---|
CmdTCP | 1 |
CmdUDP | 2 |
CmdMux | 3 |
Functions
AsStream
func AsStream(c: Conn): dyn tunnel.Streamtunnel.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): ConnAcceptOver
func AcceptOver(s: dyn tunnel.Stream, users: Vector<User>): ConnMuxClient
func MuxClient(c: Conn): transport.MuxCmdMux.MuxServer
func MuxServer(c: Conn): transport.MuxCmdMux.NewVision
func NewVision(uuid: transport.Bytes): VisionFreeVision
func FreeVision(v &: Vision)VisionPad
func VisionPad(v &: Vision, p: void*, n: int, command: int, long_padding: bool): transport.BytesVisionUnpad
func VisionUnpad(v &: Vision, p: void*, n: int, out &: transport.Bytes): boolout. False on a memory failure.VisionWrite
func VisionWrite(t &: transport.Conn, v &: Vision, p: void*, n: int): boolVisionRead
func VisionRead(t &: transport.Conn, v &: Vision, buf: void*, max: int): intEncodeRequest
func EncodeRequest(r: Request): transport.Bytestransport.FreeBytes.DecodeRequest
func DecodeRequest(b: transport.Bytes, r &: Request): intReadRequest
func ReadRequest(t &: transport.Conn, r &: Request): boolNewUser
func NewUser(uuid: str, flow: str): UserClient
func Client(t: transport.Conn, uuid: str, flow: str, command: int, addr: transport.Addr): Connok 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): Conntransport.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>): Connok 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): intWrite
func Write(c &: Conn, p: void*, n: int): boolReadPacket
func ReadPacket(c &: Conn, out &: transport.Bytes): boolWritePacket
func WritePacket(c &: Conn, p: void*, n: int): boolClose
func Close(c &: Conn)ParseLink
func ParseLink(link: str): Linkvless:// link. Check ok; err says what was wrong or unsupported.FormatLink
func FormatLink(l: Link): strLinkSettings
func LinkSettings(l: Link): transport.Settingstransport.Settings that match a link: network, TLS, names, path and the early data size from ?ed=.DialLink
func DialLink(l: Link, command: int, target: transport.Addr): Conn