rekryt / amphp-vless
Async VLESS client for PHP based on Amp.
Requires
- php: >=8.2
- ext-openssl: *
- amphp/amp: ^3.1
- amphp/byte-stream: ^2.1
- amphp/hpack: ^3.2
- amphp/http: ^2.1
- amphp/socket: ^2.4
- psr/log: ^3
Requires (Dev)
- amphp/dns: ^2.4
- amphp/http-client: 5
- amphp/http-server: ^3
- amphp/http-server-form-parser: ^2
- amphp/mysql: ^3
- amphp/phpunit-util: ^3
- amphp/redis: ^2
- amphp/ssh: dev-master
- amphp/sync: ^2.3
- amphp/websocket-client: 2
- phpunit/phpunit: ^9.6
- psalm/phar: 6.0
- vlucas/phpdotenv: ^5.6
Suggests
- amphp/http-client: To make HTTP/HTTPS requests through the VLESS tunnel
- amphp/http-server: To expose the tunnel as a local HTTP proxy or reverse proxy
This package is auto-updated.
Last update: 2026-08-06 20:21:37 UTC
README
English · Русский
An asynchronous VLESS client for PHP, built on AMPHP v3.
It speaks the protocol itself. No sing-box or xray running alongside, no
local SOCKS port, no exec, no FFI. The only extension it needs is openssl,
for TLS.
The integration point is Amp\Socket\SocketConnector, so anything in the AMPHP
ecosystem that accepts a connector works through the tunnel unchanged — HTTP
clients, connection pools, WebSocket clients, or plain socket code.
use Amp\Vless\VlessConnector; $connector = VlessConnector::fromUri('vless://<uuid>@example.org:443?security=tls&type=ws&path=%2Fws%2F'); $socket = $connector->connect('tcp://one.one.one.one:80'); $socket->write("GET / HTTP/1.1\r\nHost: one.one.one.one\r\nConnection: close\r\n\r\n"); echo Amp\ByteStream\buffer($socket);
Installation
composer require rekryt/amphp-vless
Requires PHP 8.2 or newer.
Making HTTP requests
amphp/http-client needs no modification — only its connector changes.
use Amp\Http\Client\Connection\DefaultConnectionFactory; use Amp\Http\Client\Connection\UnlimitedConnectionPool; use Amp\Http\Client\HttpClientBuilder; use Amp\Http\Client\Request; $client = (new HttpClientBuilder()) ->usingPool(new UnlimitedConnectionPool(new DefaultConnectionFactory($connector))) ->build(); $response = $client->request(new Request('https://example.com/')); echo $response->getBody()->buffer();
HTTPS works end to end: TLS is negotiated with the destination across the tunnel, so certificate verification still protects the caller, and HTTP/2 is negotiated where the server offers it.
Configuration
Three ways to describe the same server, whichever suits the surrounding system.
// A share link, as a client hands it out. $config = VlessConfig::fromUri('vless://<uuid>@example.org:443?security=tls&sni=example.org&type=ws&path=%2Fws%2F'); // An array laid out like a sing-box outbound. $config = VlessConfig::fromArray([ 'server' => 'example.org', 'server_port' => 443, 'uuid' => '<uuid>', 'tls' => ['enabled' => true, 'server_name' => 'example.org'], 'transport' => ['type' => 'ws', 'path' => '/ws/'], ]); // Assembled in code. $config = new VlessConfig( server: 'example.org', port: 443, uuid: '<uuid>', tls: true, transport: VlessConfig::TRANSPORT_WS, path: '/ws/', ); $connector = new VlessConnector($config);
Transports
TLS is a separate switch, not part of the transport, so each of these works with it and without it — six combinations, none of them a special case.
type |
Also written | Supported |
|---|---|---|
tcp |
raw |
yes — direct, TLS optional |
ws |
websocket |
yes — the most common deployment |
httpupgrade |
yes — bare HTTP upgrade, no framing | |
xhttp |
splithttp |
yes — all three modes |
grpc |
yes | |
mkcp |
kcp |
no |
quic, h3 |
no | |
http, h2 |
no — and removed from Xray itself |
Xray renamed tcp to raw and accepts both spellings, as it does ws and
websocket; links from a recent panel carry the newer names, and both forms are
accepted here. An unsupported transport is refused at configuration time with an
exception that says so, distinct from the one raised for a link that is simply
malformed.
xhttp and grpc both run over HTTP/2, and the HTTP/2 client is this package's
own. An HTTP client cannot serve here: it holds the response back until the
request body is complete, and a tunnel's request body never completes.
XHTTP supports all three modes — packet-up (the default), stream-up and
stream-one — selected by mode= in the link.
What is left out. mkcp is a reliability layer over UDP and would have to be
written outright, with PHP's per-packet overhead counting against exactly the
case it exists for. quic and h3 need QUIC, which PHP does not have and
cannot get: QUIC embeds the TLS 1.3 handshake, and driving a TLS handshake by
hand is the same wall REALITY runs into.
UDP
Datagrams travel to a fixed destination inside one tunnel.
$socket = $connector->connectDatagram('udp://1.1.1.1:53'); $socket->send($dnsQuery); $answer = $socket->receive();
Worth knowing what this is: the tunnel is a TCP connection, and each datagram travels inside it behind a two-byte length prefix that restores the message boundary. Delivery and ordering therefore come from TCP — a datagram cannot be individually lost or reordered the way real UDP allows.
What cannot be supported
Two VLESS features are out of reach in PHP, and the reason is structural rather than a matter of effort. Both are refused at configuration time, with an explanation, rather than quietly degrading to plain TLS — a caller who believes their traffic is disguised should not be left believing it.
flow: xtls-rprx-vision needs the TLS record buffers and the raw socket
underneath an established TLS session. The reference implementation reaches them
through reflection and unsafe pointer arithmetic into Go's crypto/tls.Conn
private fields. PHP exposes TLS as a stream filter inside OpenSSL; neither the
decrypted buffers nor the socket below them are reachable.
REALITY needs the client to build a TLS ClientHello, stop before sending it,
overwrite bytes at a fixed offset inside the serialised message, and then carry
on with the handshake. Go's own crypto/tls cannot do that either — the
reference implementation reaches for
uTLS, a fork of it. In PHP it
would mean writing TLS 1.3 from scratch.
What that costs you
Worth knowing before you choose this library, because it decides whether it is the right tool.
Without Vision, your traffic is TLS inside TLS — an outer session with the server and an inner one with the destination. The double wrapping is visible to traffic analysis through record sizes and timing, whatever is inside it. Not adding that second layer is precisely what Vision exists to do.
Without REALITY you need a real domain with a real certificate, and the server can be identified as a proxy endpoint by active probing — which is the thing REALITY hides, by serving an unrecognised client somebody else's genuine site instead.
And a hypothetical implementation would not help: REALITY's protection rests on the ClientHello being byte-for-byte indistinguishable from a real browser's, which is why the reference client mimics specific browser fingerprints. PHP with OpenSSL has an OpenSSL fingerprint; a hand-rolled one would be unique, and therefore a beacon rather than a disguise.
So this library fits:
| An API blocked by address or domain | yes |
| A database or cache that only accepts the VPN | yes |
| Blocking by protocol signature | yes |
| DPI doing statistical traffic analysis | no — use sing-box or xray |
That last row is a boundary, not an excuse. The external process this project exists to avoid remains the only answer to active traffic analysis, and claiming otherwise would be the same deception as silently downgrading REALITY to plain TLS.
vless-proxy — the tunnel for programs that are not PHP
Most of the value is in letting everything else on the machine use the tunnel.
The package ships an executable that presents it as an ordinary local proxy, so
curl, a browser, git, ssh or anything else needs no knowledge of VLESS —
only a proxy setting.
vendor/bin/vless-proxy --uri='vless://<uuid>@example.org:443?security=tls&type=ws&path=%2Fws%2F'
With no --uri it reads VLESS_URI from the environment, which keeps the
credential out of your shell history and out of ps.
For anything beyond a quick check, prefer the file form. A vless:// link and a
proxy password are both credentials, and an environment variable is a poor place
for one: it is readable through docker inspect, through /proc/<pid>/environ,
and in whatever an orchestrator logs about the process.
VLESS_URI_FILE=/run/secrets/vless_uri \
VLESS_PROXY_PASSWORD_FILE=/run/secrets/proxy_password \
vendor/bin/vless-proxy --socks
Every setting below has a <NAME>_FILE form read from the file it names — the
convention Docker and Kubernetes secrets already use.
Options
| Option | Meaning |
|---|---|
--uri=<vless://…> |
The server. |
--socks[=addr] |
Run a SOCKS5 proxy. Default 127.0.0.1:1080. |
--http[=addr] |
Run an HTTP proxy. Default 127.0.0.1:8080. |
--user=<name> |
Require this username from clients. |
--password=<pass> |
…and this password. Both or neither. |
--timeout=<sec> |
Connect timeout for each tunnel. Default 20. |
--quiet |
Log failures only. |
--help |
Show the usage text. |
Give either proxy, or both. With neither, a SOCKS5 proxy starts on the default address.
Every option can come from the environment instead, which is how the container image is configured. Command-line options win where both are given.
| Variable | Same as | File form |
|---|---|---|
VLESS_URI |
--uri |
VLESS_URI_FILE |
VLESS_PROXY_SOCKS |
--socks |
VLESS_PROXY_SOCKS_FILE |
VLESS_PROXY_HTTP |
--http |
VLESS_PROXY_HTTP_FILE |
VLESS_PROXY_USER |
--user |
VLESS_PROXY_USER_FILE |
VLESS_PROXY_PASSWORD |
--password |
VLESS_PROXY_PASSWORD_FILE |
VLESS_PROXY_TIMEOUT |
--timeout |
VLESS_PROXY_TIMEOUT_FILE |
VLESS_PROXY_QUIET |
--quiet |
VLESS_PROXY_QUIET_FILE |
Requiring a username and password
Authentication is off until both --user and --password are given, and then
it applies to both proxies — SOCKS5 through its username/password
sub-negotiation (RFC 1929), HTTP through Proxy-Authorization and a 407
challenge (RFC 7235).
vendor/bin/vless-proxy --socks --http --user=alice --password="$PROXY_PASSWORD"
curl --socks5-hostname alice:secret@127.0.0.1:1080 https://example.com/ curl -x http://alice:secret@127.0.0.1:8080 https://example.com/
Half a pair is refused at startup rather than ignored: a deployment that set a username and lost the password would otherwise run wide open while looking configured. The password is compared in constant time, and it is never forwarded to the destination — a proxy credential is between the client and the proxy and nobody else.
--password puts the secret in ps, where every user on the machine can read
it. It is there for a quick check; use VLESS_PROXY_PASSWORD_FILE for anything
that stays running.
A refused attempt is answered after a delay that doubles with each consecutive failure from the same address, up to eight seconds, and resets on success. That turns an unlimited guess rate into a bounded one, which is what a human-chosen password needs to survive an exposed port.
Two further limits apply whether or not credentials are set: a client has ten seconds to say what it wants before the connection is dropped, and connections past 512 at once are refused rather than queued. Both exist because neither costs an attacker anything otherwise — the negotiation read happens before any credential is checked.
Using it
# Both doors at once. vendor/bin/vless-proxy --socks --http # SOCKS5 reachable from the local network, HTTP kept to this machine. vendor/bin/vless-proxy --socks=0.0.0.0:1080 --http=127.0.0.1:8080
curl --socks5-hostname 127.0.0.1:1080 https://example.com/
http_proxy=http://127.0.0.1:8080 curl http://example.com/
https_proxy=http://127.0.0.1:8080 curl https://example.com/
git -c http.proxy=http://127.0.0.1:8080 clone https://example.com/repo.git
ssh -o ProxyCommand='nc -X 5 -x 127.0.0.1:1080 %h %p' user@host
Note --socks5-hostname rather than --socks5: it has the proxy resolve the
name, so the destination is never looked up on this machine. Plain --socks5
resolves locally and announces where you are going before the connection is
made.
Which door. SOCKS5 is the wider one — it carries any protocol, and ssh -D,
browsers and most networked software speak it directly. The HTTP proxy exists
for software that only understands http_proxy; it handles both absolute-form
requests and CONNECT, so HTTPS goes through it too.
allowInsecure in a link. A link may carry allowInsecure=1, which turns
off certificate verification — the only check that proves the far end is the
server the link named. It still works, because a self-signed certificate on a
private stand is a real use, but the process says so loudly at startup every
time. Traffic on such a tunnel can be read and changed by anyone on the path,
whatever security=tls in the link suggests.
Control characters in a link. A link whose path, host, sni or
serviceName contains one is refused at configuration time. Those values are
written into an HTTP/1.1 handshake, where a newline ends a header and starts
another — a crafted link could otherwise append headers or a whole second
request. This matters because links arrive from subscriptions, not only from
your own notes.
Binding to 0.0.0.0. Without credentials this makes the tunnel available to
everyone who can reach the port, and an open proxy on a routable address is
found by scanners within hours. Set --user and --password, or keep the bind
on 127.0.0.1. The process prints a loud warning at startup when it is
listening beyond loopback with no credentials set.
Docker
The repository ships a Dockerfile and a compose template, so the proxy can run
as a service without PHP installed on the host.
cp docker-compose.example.yml docker-compose.yml $EDITOR docker-compose.yml # at minimum, set VLESS_URI docker compose up -d
curl --socks5-hostname 127.0.0.1:1080 https://ifconfig.me/ip curl -x http://127.0.0.1:8080 https://ifconfig.me/ip
Nothing is baked into the image but the code. The server, the ports and the
credentials all arrive as the environment variables listed above, so one image
serves every configuration and no .env file is involved — the container reads
the process environment only.
docker-compose.yml is gitignored; the example is the template and carries no
real values. A vless:// link is a credential, and so is the proxy password.
Two things the template sets deliberately:
- Ports are published to
127.0.0.1."127.0.0.1:1080:1080"means only this machine can connect, whatever the container binds internally. Dropping the prefix serves other hosts — setVLESS_PROXY_USERandVLESS_PROXY_PASSWORDbefore you do. - The container binds
0.0.0.0. It has to, or the published port would never reach the process. What decides who can connect is the mapping above, not the bind.
Without compose:
docker build -t amphp-vless . docker run --rm -p 127.0.0.1:1080:1080 \ -e VLESS_URI='vless://<uuid>@example.org:443?security=tls&type=ws&path=%2Fws%2F' \ -e VLESS_PROXY_USER=alice \ -e VLESS_PROXY_PASSWORD="$PROXY_PASSWORD" \ amphp-vless
The image installs with --no-dev, so it carries the protocol implementation
and nothing else: no test framework, no static analyser, no HTTP client. It runs
as nobody on a read-only filesystem.
Embedding it instead
Both inbounds are ordinary classes, if a program should run one itself rather than shell out:
use Amp\Vless\Server\Socks5ProxyServer; $server = new Socks5ProxyServer(new VlessConnector($config)); $server->listen('127.0.0.1:1080');
Two further shapes have examples of their own: TCP port forwarding, which carries anything because it never interprets the bytes, and a reverse proxy, which serves one otherwise unreachable site at a local address.
Examples
Every claim above has a runnable example. Copy .env.example to .env, set
VLESS_TEST_URI, then run any of them directly:
php examples/01-tcp-raw.php
Each prints what it demonstrates and what to expect, and each ends with the findings worth carrying away rather than only its output.
What the library does
| Example | What it demonstrates |
|---|---|
01-tcp-raw.php |
The smallest thing here: a plain socket that happens to reach the internet from somewhere else. No HTTP client, no framework — bytes in, bytes out. |
02-http-client.php |
HTTP and HTTPS through amphp/http-client, unmodified. Only the connector changes; HTTPS negotiates HTTP/2 end to end. |
03-verify-tunnel.php |
Compares the address the internet sees with and without the tunnel. Inconclusive when your machine egresses through the same network as the server, and says so rather than claiming success. |
04-concurrent.php |
Eight requests at once against the same eight in sequence — the argument against running a proxy process beside the application. |
05-config-variants.php |
The three ways to describe a server — share link, sing-box array, constructor — and what is refused outright. |
06-cancellation.php |
Deadlines and cancellation across every layer of the tunnel, including a cancellation that arrives mid-handshake. |
07-streaming.php |
A large download read as a stream, with memory measured to prove it never accumulates. |
08-websocket.php |
An application WebSocket through the tunnel — not to be confused with WebSocket as the tunnel's own transport. |
09-dns-udp.php |
UDP: a real DNS exchange with datagram boundaries preserved by the protocol's length framing. |
10-connection-reuse.php |
How many tunnels a run of requests actually opens, and what a connection pool saves. |
11-http-proxy-server.php |
The local inbounds: SOCKS5 and HTTP together, driven by curl. What vless-proxy runs. |
12-reverse-proxy.php |
A site reachable only through the tunnel, served at a local address. No client configuration at all. |
13-tcp-port-forward.php |
A forwarded TCP port — SSH, a database, anything — because it never interprets the bytes. |
Running it in an application
These cover what happens after the first successful request: what each failure means, and how the tunnel fits into a program that has to keep working.
| Example | What it demonstrates |
|---|---|
14-diagnostics.php |
Ten deliberate failures — bad link, Vision, REALITY, dead port, wrong path, wrong uuid — each naming its exception and its fix. Needs no configuration: it builds its own broken configs and its own misbehaving servers on loopback. |
15-no-dns-leak.php |
Proof that the destination name never reaches this machine's resolver. A recording resolver captures every lookup; only the server's own hostname appears. |
16-split-tunnel.php |
Some traffic through the tunnel, the rest direct, as a SocketConnector decorator — with the routing proved, not assumed. |
17-failover.php |
Several servers: measuring them, racing them, and switching off one that dies. Includes the timing mistake that makes a failover work only when it is not needed. |
18-database.php |
MySQL and Redis that only accept connections from inside the VPN. One constructor argument each. |
19-timeouts-retries.php |
The handshake measured layer by layer, and what to set when it is four deep. Values tuned for a direct connection cut off requests that were fine. |
20-worker.php |
A long-running process: backoff with jitter, a cap on concurrent tunnels, and which failures are worth retrying at all. |
21-upload-streaming.php |
A large body sent without buffering, and the cost of buffering it measured in megabytes. |
22-chaining.php |
A tunnel through a tunnel, and — the useful case — a tunnel built on top of whatever connector can leave your network. |
23-subscription.php |
Reading a subscription list, sorting it, and refusing what cannot be carried instead of degrading quietly. |
24-form-post.php |
Form submissions in both directions: built and sent out, then received, parsed and relayed. |
25-telegram.php |
A bot posting to Telegram through the tunnel — the case this library is most often wanted for — with Telegram's own error shapes handled. |
26-transports.php |
The same request over every transport that is configured, showing that which one a server speaks never reaches your code. |
27-ssh-tunnel.php |
ssh -L without shelling out: a local port forwarded from an SSH server that is itself reached through the tunnel. Two tunnels stacked, neither library aware of the other. |
Testing
composer test # unit and mock-level suites composer psalm # static analysis composer check # both composer test-e2e # against a real server, needs .env
composer test passes on a machine with no credentials at all: the end-to-end
suite skips itself when VLESS_TEST_URI is absent.
An end-to-end exchange that returns nothing at all is tried once more. VLESS has
no reply saying whether the destination was reached, so a connection lost in
transit arrives as an ordinary end of stream — indistinguishable from a client
that is broken. Only silence is retried; anything that arrived is judged as it
came. VLESS_E2E_PACING_SECONDS adds a gap between tests for a server that
rate-limits.
The protocol codec is checked against byte vectors generated by the upstream Go
implementation (tools/gen-vectors, pinned to the sing-vmess revision that
sing-box 1.13.16 uses). Comparing the encoder against numbers the encoder itself
produced would prove nothing, so those fixtures are the only accepted source of
truth for the wire format.
Credits
The protocol was implemented by reading
sing-vmess and
sing-box, with
Xray-core for cross-checking. The
connector architecture follows
amphp/http-tunnel.
License
MIT. See LICENSE.