deploy/phpbox: cupsexit.php - a phpbox exit that speaks cups.online

The censored client can only reach cups.online, so the exit must be a cups
participant too: cupsexit.php joins the same room, reads the client's stream
mux frames hidden in cursor integers (same auth + Centrifuge WS + cursor
codec + packet/chunk framing as transport/cupsonline, verified byte-for-byte
against the Go code), demuxes OPEN/DATA/CLOSE, dials dst on :80/:443, and
writes replies back as cursors. Select loop over the WS and dst sockets for
~140s per request. Guards: token, ports 80/443, no private targets. Offline
framing self-test (php cupsexit.php selftest) is green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
p1neappleXpress
2026-09-30 01:30:03 +03:00
co-authored by Claude Opus 4.8
parent 345062f309
commit d081af72cb
2 changed files with 451 additions and 0 deletions
+24
View File
@@ -47,3 +47,27 @@ Guards: a shared token, only ports 80/443, and no private/loopback targets.
v0 carries frames in the clear, so the host and anything on-path see the
destinations and traffic (the app's own TLS/MTProto still protects content).
Wrap the carrier with the core's encryption before any real use.
## cupsexit.php — the cups path (for the censored client)
When the client can only reach cups.online (not the PHP host directly), use
`cupsexit.php` instead of `phpbox.php`. It joins the **same cups room** as the
client and relays the stream mux there:
```
client (--mode=stream, --transport=cupsonline, --url <room>)
-> cups.online room -> cupsexit.php -> dst
```
`cupsexit.php` is a cups participant (same auth + Centrifuge WS + cursor
encoding as `transport/cupsonline`) plus the mux demux and `fsockopen(dst)`.
It carries no IP packets and runs no gVisor.
Run (open in a browser so the host's bot check passes; keep re-opening or
point a pinger at it — one request serves ~140 s):
```
https://<host>/cupsexit.php?k=PHPBOX_TOKEN&url=<full cups room URL>
```
Offline framing self-test (no network): `php cupsexit.php selftest`.
+427
View File
@@ -0,0 +1,427 @@
<?php
/**
* cupsexit.php - a phpbox exit that talks to the client over cups.online.
*
* The censored client can only reach cups.online, so the chain is
* client -> cups.online (a shared-editor room) -> this PHP -> dst
* This script joins the SAME cups room as the client, reads the client's
* stream-mux frames (OPEN/DATA/CLOSE) hidden in cursor integers, dials the
* destinations with fsockopen, and writes the replies back into the room as
* cursors. It IS a cups participant (like transport/cupsonline) plus the mux
* demux - no binary, no daemon, no listening socket needed.
*
* Run on the host (open in a browser so the host's bot check passes):
* https://<host>/cupsexit.php?k=TOKEN&url=<full cups room URL>
* It runs for RUN_CAP seconds as one cups user, then returns; re-open (or a
* pinger) to keep an exit present. The Go client uses --mode=stream with the
* same room URL.
*
* Guards: shared token, ports 80/443 only, no private/loopback targets.
* v0 is plaintext at the mux layer (the cups payload is stego, not secret);
* wrap with the core's encryption before real use.
*
* Framing matches transport/cupsonline exactly: bytes -> 6-byte big-endian
* numbers -> {row,column} cursors; a message is (2-byte chunk len + chunk),
* and the reassembled stream is a series of (2-byte packet len + packet),
* where each packet is one mux frame:
* type(1) | stream_id(4 BE) | len(4 BE) | payload (1 OPEN "host:port",
* 2 DATA, 3 CLOSE, 4 OPEN_OK, 5 OPEN_ERR reason)
*/
error_reporting(E_ALL & ~E_DEPRECATED);
const UA = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36';
const BYTES_PER_NUMBER = 6;
const RUN_CAP = 140; // seconds this exit stays in the room per request
const DIAL_TO = 6;
const SEND_INTERVAL = 0.018; // s between cursor messages (cups throttles)
const MAX_MSG_DATA = 3000; // bytes of payload per cursor message
const OPEN = 1, DATA = 2, CLOSE = 3, OPEN_OK = 4, OPEN_ERR = 5;
$TOKEN = getenv('PHPBOX_TOKEN') ?: 'CHANGE-ME';
// Offline: round-trip the exit's whole framing chain (mux frame -> packet ->
// chunk -> cursors -> back) with no network. `php cupsexit.php selftest`.
if (PHP_SAPI === 'cli' && ($argv[1] ?? '') === 'selftest') { CupsExit::selfTest(); exit; }
header('Content-Type: text/plain; charset=utf-8');
if (!hash_equals($TOKEN, (string)($_GET['k'] ?? ''))) { http_response_code(404); exit("no\n"); }
$roomURL = $_GET['url'] ?? '';
if ($roomURL === '') { http_response_code(400); exit("need ?url=<cups room URL>\n"); }
@set_time_limit(0);
while (ob_get_level() > 0) { ob_end_flush(); }
$exit = new CupsExit($roomURL);
$exit->run();
// ===========================================================================
class CupsExit
{
private array $auth;
private $ws;
private string $cookieFile;
private string $recvBuf = ''; // reassembled cups packet stream
private string $muxBuf = ''; // reassembled mux frame stream
private array $socks = []; // stream_id => dst resource
private float $lastSend = 0.0;
public function __construct(private string $roomURL) {
$this->cookieFile = tempnam(sys_get_temp_dir(), 'cupsx');
}
/** selfTest round-trips the framing chain offline (no cups). */
public static function selfTest(): void
{
$x = new self('cli');
$ok = true;
foreach (['', 'A', 'hello', str_repeat('Z', 250)] as $p) {
$frame = pack('CNN', DATA, 7, strlen($p)) . $p; // mux frame (= packet)
$blob = pack('n', strlen($frame)) . $frame; // packet framing
$msg = pack('n', strlen($blob)) . $blob; // chunk framing
$cursors = $x->cursorsFromBytes($msg);
$decoded = $x->bytesFromCursors($cursors);
$chunkLen = unpack('n', substr($decoded, 0, 2))[1];
$chunk = substr($decoded, 2, $chunkLen);
$ln = unpack('n', substr($chunk, 0, 2))[1];
$pkt = substr($chunk, 2, $ln);
$h = unpack('Ctype/Nsid/Nlen', substr($pkt, 0, 9));
$got = substr($pkt, 9, $h['len']);
$pass = ($h['type'] === DATA && $h['sid'] === 7 && $got === $p);
$ok = $ok && $pass;
printf("selftest len=%-4d %s\n", strlen($p), $pass ? 'OK' : 'FAIL');
}
echo $ok ? "SELFTEST PASS\n" : "SELFTEST FAIL\n";
exit($ok ? 0 : 1);
}
public function run(): void
{
$a = $this->authorize($this->roomURL);
if (!$a) { echo "auth failed\n"; return; }
$this->auth = $a;
echo "joined room {$a['roomUUID']} as {$a['userUUID']}\n";
if (!$this->wsJoin()) { echo "ws join failed\n"; return; }
echo "cups exit ready; serving up to " . RUN_CAP . "s\n";
$start = microtime(true);
while (microtime(true) - $start < RUN_CAP) {
if (connection_aborted()) { break; }
$read = array_merge([$this->ws], array_values($this->socks));
$w = $e = null;
if (@stream_select($read, $w, $e, 0, 200000) > 0) {
foreach ($read as $s) {
if ($s === $this->ws) { $this->onWSReadable(); }
else { $this->onDstReadable($s); }
}
}
}
foreach ($this->socks as $s) { @fclose($s); }
@fclose($this->ws);
@unlink($this->cookieFile);
echo "exit done\n";
}
// ---- cups receive: WS -> cursors -> bytes -> packets -> mux frames -----
private function onWSReadable(): void
{
$msg = $this->wsRead(0.2);
if ($msg === null || $msg === '') { return; }
foreach (explode("\n", trim($msg)) as $line) {
if ($line === '' ) { continue; }
if ($line === '{}') { $this->wsWriteText('{}'); continue; }
$obj = json_decode($line, true);
if (!is_array($obj)) { continue; }
$cursors = $this->peerCursors($obj);
if ($cursors === null) { continue; }
$this->ingestCursors($cursors);
}
}
private function ingestCursors(array $cursors): void
{
$decoded = $this->bytesFromCursors($cursors);
if (strlen($decoded) < 2) { return; }
$chunkLen = unpack('n', substr($decoded, 0, 2))[1];
if (2 + $chunkLen > strlen($decoded)) { return; }
$this->recvBuf .= substr($decoded, 2, $chunkLen);
// pull whole packets (2-byte len + packet); each packet is mux bytes
while (strlen($this->recvBuf) >= 2) {
$ln = unpack('n', substr($this->recvBuf, 0, 2))[1];
if ($ln === 0) { $this->recvBuf = ''; break; }
if (strlen($this->recvBuf) < 2 + $ln) { break; }
$pkt = substr($this->recvBuf, 2, $ln);
$this->recvBuf = substr($this->recvBuf, 2 + $ln);
$this->muxBuf .= $pkt;
}
$this->drainMux();
}
private function drainMux(): void
{
while (strlen($this->muxBuf) >= 9) {
$h = unpack('Ctype/Nsid/Nlen', substr($this->muxBuf, 0, 9));
if (strlen($this->muxBuf) < 9 + $h['len']) { break; }
$payload = substr($this->muxBuf, 9, $h['len']);
$this->muxBuf = substr($this->muxBuf, 9 + $h['len']);
$this->applyMux($h['type'], $h['sid'], $payload);
}
}
private function applyMux(int $type, int $sid, string $payload): void
{
if ($type === OPEN) {
[$host, $port] = array_pad(explode(':', $payload, 2), 2, '');
if (!in_array((int)$port, [80, 443], true)) { $this->sendFrame(OPEN_ERR, $sid, 'port'); return; }
if ($this->isPrivate($host)) { $this->sendFrame(OPEN_ERR, $sid, 'blocked'); return; }
$s = @stream_socket_client("tcp://$host:$port", $e, $es, DIAL_TO, STREAM_CLIENT_CONNECT);
if ($s) { stream_set_blocking($s, false); $this->socks[$sid] = $s; $this->sendFrame(OPEN_OK, $sid, ''); }
else { $this->sendFrame(OPEN_ERR, $sid, (string)$es); }
} elseif ($type === DATA && isset($this->socks[$sid])) {
@fwrite($this->socks[$sid], $payload);
} elseif ($type === CLOSE && isset($this->socks[$sid])) {
@fclose($this->socks[$sid]); unset($this->socks[$sid]);
}
}
// ---- dst -> client -----------------------------------------------------
private function onDstReadable($s): void
{
$sid = array_search($s, $this->socks, true);
if ($sid === false) { return; }
$d = @fread($s, MAX_MSG_DATA - 32);
if ($d === '' || $d === false) {
if (feof($s)) { $this->sendFrame(CLOSE, $sid, ''); @fclose($s); unset($this->socks[$sid]); }
return;
}
$this->sendFrame(DATA, $sid, $d);
}
/** sendFrame: mux frame -> packet framing -> chunk framing -> cursors -> WS. */
private function sendFrame(int $type, int $sid, string $payload): void
{
$frame = pack('CNN', $type, $sid, strlen($payload)) . $payload; // mux frame = packet
$blob = pack('n', strlen($frame)) . $frame; // packet framing
// chunk if bigger than one message (rare for control frames / small reads)
for ($off = 0; $off < strlen($blob); $off += MAX_MSG_DATA) {
$chunk = substr($blob, $off, MAX_MSG_DATA);
$msgPayload = pack('n', strlen($chunk)) . $chunk; // chunk framing
$this->pace();
$this->publishCursors($this->cursorsFromBytes($msgPayload));
}
}
private function pace(): void
{
$wait = SEND_INTERVAL - (microtime(true) - $this->lastSend);
if ($wait > 0) { usleep((int)($wait * 1e6)); }
$this->lastSend = microtime(true);
}
private function publishCursors(array $cursors): void
{
$this->wsWriteText(json_encode([
'rpc' => ['method' => 'shared_editor_change_cursors',
'data' => ['cursors' => $cursors, 'ranges' => [],
'room' => $this->auth['roomUUID'], 'user' => $this->auth['userUUID']]],
'id' => random_int(100, 1 << 20),
]));
}
// ---- cursor codec (matches transport/cupsonline) -----------------------
private function cursorsFromBytes(string $blob): array
{
$n = intdiv(strlen($blob) + BYTES_PER_NUMBER - 1, BYTES_PER_NUMBER);
$cur = [];
$nums = [];
for ($i = 0; $i < $n; $i++) {
$v = 0;
for ($j = 0; $j < BYTES_PER_NUMBER; $j++) {
$v *= 256;
$idx = $i * BYTES_PER_NUMBER + $j;
if ($idx < strlen($blob)) { $v += ord($blob[$idx]); }
}
$nums[] = $v;
}
for ($i = 0; $i < count($nums); $i += 2) {
$cur[] = ['row' => $nums[$i], 'column' => $nums[$i + 1] ?? 0];
}
return $cur;
}
private function bytesFromCursors(array $cursors): string
{
$out = '';
foreach ($cursors as $c) {
foreach ([(int)($c['row'] ?? 0), (int)($c['column'] ?? 0)] as $v) {
$b = '';
for ($j = 0; $j < BYTES_PER_NUMBER; $j++) { $b = chr($v % 256) . $b; $v = intdiv($v, 256); }
$out .= $b;
}
}
return $out;
}
private function peerCursors(array $obj): ?array
{
$data = $obj['push']['pub']['data'] ?? null;
if (!is_array($data) || ($data['type'] ?? '') !== 'cursors_update') { return null; }
$p = $data['payload'] ?? null;
if (!is_array($p) || ($p['user_uuid'] ?? '') === $this->auth['userUUID']) { return null; }
$cur = $p['cursors'] ?? null;
return is_array($cur) && $cur ? $cur : null;
}
// ---- auth + websocket (as transport/cupsonline) ------------------------
private function authorize(string $roomURL): ?array
{
[$html, $code] = $this->http($roomURL, 'GET', null, []);
if ($code !== 200) { echo "GET room status $code\n"; return null; }
$a = [
'roomUUID' => $this->scrape('/data-room="\{&quot;uuid&quot;:\s*&quot;([0-9a-f-]{36})&quot;/', $html),
'userUUID' => $this->scrape('/data-user="\{&quot;uuid&quot;:\s*&quot;([0-9a-f-]{36})&quot;/', $html),
'connToken' => $this->scrape('/<meta[^>]+name="centrifuge-connection-token"[^>]+content="([^"]+)"/', $html),
'connURL' => $this->scrape('/<meta[^>]+name="centrifuge-connection-url"[^>]+content="([^"]+)"/', $html),
'subURL' => $this->scrape('/<meta[^>]+name="centrifuge-subscription-token-url"[^>]+content="([^"]+)"/', $html),
];
foreach ($a as $k => $v) { if ($v === '') { echo "missing $k\n"; return null; } }
$a['csrf'] = $this->cookie('csrftoken');
$a['channel'] = '$shared_editor:room-' . $a['roomUUID'];
[$body, $c2] = $this->http($a['subURL'], 'POST', json_encode(['channel' => $a['channel']]),
['Content-Type: application/json', 'X-CSRFToken: ' . $a['csrf'],
'Origin: ' . $this->originOf($roomURL), 'Referer: ' . $roomURL]);
if ($c2 !== 200) { echo "sub token status $c2\n"; return null; }
$a['subToken'] = json_decode($body, true)['token'] ?? '';
if ($a['subToken'] === '') { echo "empty sub token\n"; return null; }
return $a;
}
private function wsJoin(): bool
{
$wsURL = rtrim(preg_replace('#^http#', 'ws', $this->auth['connURL']), '/') . '/websocket';
$p = parse_url($wsURL);
$secure = ($p['scheme'] === 'wss');
$host = $p['host'];
$port = $p['port'] ?? ($secure ? 443 : 80);
$cookies = [];
foreach (@file($this->cookieFile) ?: [] as $l) {
$c = explode("\t", trim($l));
if (count($c) === 7) { $cookies[] = $c[5] . '=' . $c[6]; }
}
$this->ws = @stream_socket_client(($secure ? 'ssl' : 'tcp') . "://$host:$port", $e, $es, 10, STREAM_CLIENT_CONNECT);
if (!$this->ws) { echo "ws dial: $es\n"; return false; }
$key = base64_encode(random_bytes(16));
fwrite($this->ws, "GET {$p['path']} HTTP/1.1\r\nHost: $host\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n"
. "Sec-WebSocket-Key: $key\r\nSec-WebSocket-Version: 13\r\nUser-Agent: " . UA . "\r\n"
. "Origin: " . $this->originOf($this->auth['connURL']) . "\r\n"
. ($cookies ? "Cookie: " . implode('; ', $cookies) . "\r\n" : '') . "\r\n");
$resp = '';
while (!feof($this->ws)) { $resp .= fgets($this->ws); if (str_contains($resp, "\r\n\r\n")) break; }
if (!str_contains($resp, ' 101 ')) { echo "ws handshake failed\n"; return false; }
$this->wsWriteText(json_encode(['id' => 1, 'connect' => ['token' => $this->auth['connToken'], 'name' => 'js']]));
if (!$this->wsExpect(1)) { return false; }
$this->wsWriteText(json_encode(['id' => 2, 'subscribe' => ['channel' => $this->auth['channel'], 'token' => $this->auth['subToken']]]));
return $this->wsExpect(2);
}
private function wsExpect(int $id): bool
{
$deadline = microtime(true) + 12;
while (microtime(true) < $deadline) {
$f = $this->wsRead(3.0);
if ($f === null) { continue; }
if ($f === '{}') { $this->wsWriteText('{}'); continue; }
foreach (explode("\n", trim((string)$f)) as $line) {
$o = json_decode($line, true);
if (is_array($o) && (($o['id'] ?? null) === $id)) { return true; }
}
}
return false;
}
private function wsWriteText(string $payload): void
{
$len = strlen($payload);
$frame = chr(0x81);
$mask = random_bytes(4);
if ($len < 126) { $frame .= chr(0x80 | $len); }
elseif ($len < 65536) { $frame .= chr(0x80 | 126) . pack('n', $len); }
else { $frame .= chr(0x80 | 127) . pack('J', $len); }
$frame .= $mask;
for ($i = 0; $i < $len; $i++) { $frame .= $payload[$i] ^ $mask[$i % 4]; }
@fwrite($this->ws, $frame);
}
private function wsRead(float $timeout): ?string
{
stream_set_timeout($this->ws, (int)$timeout, (int)(($timeout - (int)$timeout) * 1e6));
$b0 = @fread($this->ws, 1);
if ($b0 === '' || $b0 === false) { return null; }
$b1 = @fread($this->ws, 1);
if ($b1 === '' || $b1 === false) { return null; }
$opcode = ord($b0) & 0x0f;
$len = ord($b1) & 0x7f;
$masked = (ord($b1) & 0x80) !== 0;
if ($len === 126) { $len = unpack('n', fread($this->ws, 2))[1]; }
elseif ($len === 127) { $len = unpack('J', fread($this->ws, 8))[1]; }
$mask = $masked ? fread($this->ws, 4) : '';
$data = '';
while (strlen($data) < $len) {
$chunk = fread($this->ws, $len - strlen($data));
if ($chunk === '' || $chunk === false) { break; }
$data .= $chunk;
}
if ($masked && $mask !== '') { for ($i = 0; $i < strlen($data); $i++) { $data[$i] = $data[$i] ^ $mask[$i % 4]; } }
if ($opcode === 0x8) { return null; }
if ($opcode === 0x9) { $this->wsWritePong($data); return ''; }
if ($opcode === 0xA) { return ''; }
return $data;
}
private function wsWritePong(string $payload): void
{
$mask = random_bytes(4);
$frame = chr(0x8A) . chr(0x80 | strlen($payload)) . $mask;
for ($i = 0; $i < strlen($payload); $i++) { $frame .= $payload[$i] ^ $mask[$i % 4]; }
@fwrite($this->ws, $frame);
}
// ---- small helpers -----------------------------------------------------
private function http(string $url, string $method, ?string $body, array $headers): array
{
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true, CURLOPT_USERAGENT => UA,
CURLOPT_COOKIEJAR => $this->cookieFile, CURLOPT_COOKIEFILE => $this->cookieFile,
CURLOPT_FOLLOWLOCATION => true, CURLOPT_TIMEOUT => 30,
CURLOPT_HTTPHEADER => array_merge(['Accept-Language: ru-RU,ru;q=0.9'], $headers),
]);
if ($method === 'POST') { curl_setopt($ch, CURLOPT_POST, true); curl_setopt($ch, CURLOPT_POSTFIELDS, $body); }
$out = curl_exec($ch);
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
return [$out ?: '', $code];
}
private function scrape(string $re, string $html): string { return preg_match($re, $html, $m) ? $m[1] : ''; }
private function cookie(string $name): string
{
foreach (@file($this->cookieFile) ?: [] as $l) {
$p = explode("\t", trim($l));
if (count($p) === 7 && $p[5] === $name) { return $p[6]; }
}
return '';
}
private function originOf(string $u): string { $p = parse_url($u); return $p['scheme'] . '://' . $p['host']; }
private function isPrivate(string $host): bool
{
if (getenv('PHPBOX_ALLOW_PRIVATE') === '1') { return false; }
$ip = filter_var($host, FILTER_VALIDATE_IP) ? $host : gethostbyname($host);
return !filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE);
}
}