rekryt/amphp-vless

Async VLESS client for PHP based on Amp.

Maintainers

Package info

github.com/rekryt/amphp-vless

pkg:composer/rekryt/amphp-vless

Transparency log

Statistics

Installs: 3

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

v1.1.1 2026-08-06 19:53 UTC

This package is auto-updated.

Last update: 2026-08-06 20:21:37 UTC


README

License

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 — set VLESS_PROXY_USER and VLESS_PROXY_PASSWORD before 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.