Files
2026-07-13 13:09:14 +08:00

472 lines
13 KiB
PHP

<?php
declare(strict_types=1);
/*
* DBX HTTP Script Tunnel
*
* Upload this file to a PHP server that can reach the target database.
*
* Required configuration:
* DBX_TUNNEL_TOKEN=change-this-to-a-long-random-string
*
* Optional configuration:
* DBX_TUNNEL_DIR=/tmp/dbx_tunnel
* DBX_TUNNEL_ALLOWED_HOSTS=mysql.internal,postgres.internal
* DBX_TUNNEL_MAX_SESSION_SECONDS=3600
* DBX_TUNNEL_PHP=/usr/bin/php
*/
$DBX_TUNNEL_TOKEN = getenv('DBX_TUNNEL_TOKEN') ?: '';
$DBX_TUNNEL_DIR = getenv('DBX_TUNNEL_DIR') ?: sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'dbx_tunnel';
$DBX_TUNNEL_ALLOWED_HOSTS = array_values(array_filter(array_map('trim', explode(',', getenv('DBX_TUNNEL_ALLOWED_HOSTS') ?: ''))));
$DBX_TUNNEL_MAX_SESSION_SECONDS = max(30, (int) (getenv('DBX_TUNNEL_MAX_SESSION_SECONDS') ?: '3600'));
if (PHP_SAPI === 'cli' && isset($argv[1]) && $argv[1] === '--dbx-worker') {
run_worker($argv[2] ?? '', $argv[3] ?? '', (int) ($argv[4] ?? 0), (int) ($argv[5] ?? 10));
exit;
}
try {
require_token();
cleanup_old_sessions();
$action = request_param('dbx_action');
switch ($action) {
case 'open':
handle_open();
break;
case 'write':
handle_write();
break;
case 'read':
handle_read();
break;
case 'close':
handle_close();
break;
default:
respond_error(400, 'Unsupported dbx_action');
}
} catch (Throwable $e) {
respond_error(500, $e->getMessage());
}
function handle_open(): void
{
$session = request_session();
$host = request_param('dbx_target_host');
$port = (int) request_param('dbx_target_port');
$connectTimeout = request_connect_timeout();
validate_target($host, $port);
$dir = session_dir($session);
if (!is_dir($dir) && !mkdir($dir, 0700, true) && !is_dir($dir)) {
respond_error(500, 'Failed to create tunnel session directory');
}
file_put_contents($dir . DIRECTORY_SEPARATOR . 'meta.json', json_encode([
'target_host' => $host,
'target_port' => $port,
'created_at' => time(),
]));
touch($dir . DIRECTORY_SEPARATOR . 'in.queue');
touch($dir . DIRECTORY_SEPARATOR . 'out.queue');
if (function_exists('fastcgi_finish_request')) {
respond_json(['ok' => true], false);
fastcgi_finish_request();
run_worker($dir, $host, $port, $connectTimeout);
exit;
}
if (spawn_worker($dir, $host, $port, $connectTimeout)) {
if (wait_for_worker_start($dir, 2000)) {
respond_json(['ok' => true]);
}
respond_error(500, 'PHP tunnel worker did not start');
}
respond_error(500, 'PHP tunnel requires PHP-FPM fastcgi_finish_request or permission to spawn PHP CLI');
}
function handle_write(): void
{
$dir = existing_session_dir(request_session());
ensure_session_alive($dir);
$data = file_get_contents('php://input');
if ($data !== false && $data !== '') {
append_chunk($dir, 'in', $data);
}
respond_json(['ok' => true]);
}
function handle_read(): void
{
$dir = existing_session_dir(request_session());
$waitMs = max(0, min(5000, (int) (request_param('dbx_wait_ms', '1000'))));
$deadline = microtime(true) + ($waitMs / 1000);
do {
$data = drain_chunks($dir, 'out');
if ($data !== '') {
header('Content-Type: application/octet-stream');
header('Cache-Control: no-store');
echo $data;
return;
}
if (is_file($dir . DIRECTORY_SEPARATOR . 'error.txt')) {
respond_error(502, trim((string) file_get_contents($dir . DIRECTORY_SEPARATOR . 'error.txt')));
}
if (is_file($dir . DIRECTORY_SEPARATOR . 'closed')) {
respond_error(410, 'Tunnel session closed');
}
usleep(20000);
} while (microtime(true) < $deadline);
http_response_code(204);
}
function handle_close(): void
{
$dir = existing_session_dir(request_session());
touch($dir . DIRECTORY_SEPARATOR . 'close');
respond_json(['ok' => true]);
}
function run_worker(string $dir, string $host, int $port, int $connectTimeout): void
{
global $DBX_TUNNEL_MAX_SESSION_SECONDS;
if ($dir === '' || $host === '' || $port <= 0) {
return;
}
ignore_user_abort(true);
set_time_limit(0);
@touch($dir . DIRECTORY_SEPARATOR . 'worker.started');
$targetHost = strpos($host, ':') !== false && substr($host, 0, 1) !== '[' ? '[' . $host . ']' : $host;
$socket = @stream_socket_client(
'tcp://' . $targetHost . ':' . $port,
$errno,
$errstr,
max(1, min(300, $connectTimeout)),
STREAM_CLIENT_CONNECT
);
if (!$socket) {
write_error($dir, 'Failed to connect target database: ' . $errstr);
mark_closed($dir);
return;
}
stream_set_blocking($socket, false);
$expiresAt = time() + $DBX_TUNNEL_MAX_SESSION_SECONDS;
$lastActivity = time();
try {
while (time() < $expiresAt) {
if (is_file($dir . DIRECTORY_SEPARATOR . 'close')) {
break;
}
$inbound = drain_chunks($dir, 'in');
if ($inbound !== '') {
write_all($socket, $inbound);
$lastActivity = time();
}
$read = [$socket];
$write = [];
$except = [];
$ready = @stream_select($read, $write, $except, 0, 200000);
if ($ready === false) {
write_error($dir, 'Failed to poll target database socket');
break;
}
if ($ready > 0) {
$data = fread($socket, 16384);
if ($data === false) {
write_error($dir, 'Failed to read target database socket');
break;
}
if ($data === '') {
if (feof($socket)) {
break;
}
} else {
append_chunk($dir, 'out', $data);
$lastActivity = time();
}
}
if (time() - $lastActivity > $DBX_TUNNEL_MAX_SESSION_SECONDS) {
break;
}
}
} catch (Throwable $e) {
write_error($dir, $e->getMessage());
}
fclose($socket);
mark_closed($dir);
}
function write_all($socket, string $data): void
{
$offset = 0;
$length = strlen($data);
while ($offset < $length) {
$written = fwrite($socket, substr($data, $offset));
if ($written === false) {
throw new RuntimeException('Failed to write target database socket');
}
if ($written === 0) {
usleep(10000);
continue;
}
$offset += $written;
}
}
function spawn_worker(string $dir, string $host, int $port, int $connectTimeout): bool
{
if (!function_exists('popen')) {
return false;
}
$php = getenv('DBX_TUNNEL_PHP') ?: PHP_BINARY;
if ($php === '') {
return false;
}
$cmd = escapeshellarg($php)
. ' ' . escapeshellarg(__FILE__)
. ' --dbx-worker ' . escapeshellarg($dir)
. ' ' . escapeshellarg($host)
. ' ' . escapeshellarg((string) $port)
. ' ' . escapeshellarg((string) $connectTimeout)
. ' > /dev/null 2>&1 &';
$handle = @popen($cmd, 'r');
if (!is_resource($handle)) {
return false;
}
@pclose($handle);
return true;
}
function wait_for_worker_start(string $dir, int $timeoutMs): bool
{
$deadline = microtime(true) + ($timeoutMs / 1000);
do {
if (is_file($dir . DIRECTORY_SEPARATOR . 'worker.started')) {
return true;
}
usleep(20000);
} while (microtime(true) < $deadline);
return false;
}
function append_chunk(string $dir, string $name, string $data): void
{
if ($data === '') {
return;
}
$lock = fopen($dir . DIRECTORY_SEPARATOR . $name . '.lock', 'c');
if (!$lock) {
throw new RuntimeException('Failed to open tunnel queue lock');
}
flock($lock, LOCK_EX);
file_put_contents($dir . DIRECTORY_SEPARATOR . $name . '.queue', base64_encode($data) . "\n", FILE_APPEND | LOCK_EX);
flock($lock, LOCK_UN);
fclose($lock);
}
function drain_chunks(string $dir, string $name): string
{
$path = $dir . DIRECTORY_SEPARATOR . $name . '.queue';
if (!is_file($path)) {
return '';
}
$lock = fopen($dir . DIRECTORY_SEPARATOR . $name . '.lock', 'c');
if (!$lock) {
throw new RuntimeException('Failed to open tunnel queue lock');
}
flock($lock, LOCK_EX);
$encoded = (string) file_get_contents($path);
file_put_contents($path, '');
flock($lock, LOCK_UN);
fclose($lock);
$decoded = '';
foreach (explode("\n", $encoded) as $line) {
if ($line === '') {
continue;
}
$chunk = base64_decode($line, true);
if ($chunk !== false) {
$decoded .= $chunk;
}
}
return $decoded;
}
function require_token(): void
{
global $DBX_TUNNEL_TOKEN;
if ($DBX_TUNNEL_TOKEN === '') {
respond_error(503, 'DBX_TUNNEL_TOKEN is not configured');
}
$provided = request_token();
if ($provided === '' || !hash_equals($DBX_TUNNEL_TOKEN, $provided)) {
respond_error(401, 'Invalid tunnel token');
}
}
function request_token(): string
{
if (isset($_SERVER['HTTP_X_DBX_TUNNEL_TOKEN'])) {
return trim((string) $_SERVER['HTTP_X_DBX_TUNNEL_TOKEN']);
}
$authorization = $_SERVER['HTTP_AUTHORIZATION'] ?? $_SERVER['REDIRECT_HTTP_AUTHORIZATION'] ?? '';
if (stripos($authorization, 'Bearer ') === 0) {
return trim(substr($authorization, 7));
}
return '';
}
function validate_target(string $host, int $port): void
{
global $DBX_TUNNEL_ALLOWED_HOSTS;
if ($host === '' || preg_match('/[\x00-\x20]/', $host)) {
respond_error(400, 'Invalid target host');
}
if (
!filter_var($host, FILTER_VALIDATE_IP)
&& !filter_var($host, FILTER_VALIDATE_DOMAIN, FILTER_FLAG_HOSTNAME)
) {
respond_error(400, 'Invalid target host');
}
if ($port < 1 || $port > 65535) {
respond_error(400, 'Invalid target port');
}
// Optional allow-list keeps this script from becoming a general internal TCP relay.
if ($DBX_TUNNEL_ALLOWED_HOSTS !== [] && !in_array($host, $DBX_TUNNEL_ALLOWED_HOSTS, true)) {
respond_error(403, 'Target host is not allowed');
}
}
function request_session(): string
{
$session = request_param('dbx_session');
if (!preg_match('/\A[A-Za-z0-9_-]{8,128}\z/', $session)) {
respond_error(400, 'Invalid tunnel session');
}
return $session;
}
function request_param(string $name, string $default = ''): string
{
if (isset($_GET[$name])) {
return trim((string) $_GET[$name]);
}
if (isset($_POST[$name])) {
return trim((string) $_POST[$name]);
}
return $default;
}
function request_connect_timeout(): int
{
$timeout = (int) request_param('dbx_connect_timeout', '10');
return max(1, min(300, $timeout));
}
function session_dir(string $session): string
{
global $DBX_TUNNEL_DIR;
ensure_base_dir();
return rtrim($DBX_TUNNEL_DIR, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . $session;
}
function existing_session_dir(string $session): string
{
$dir = session_dir($session);
if (!is_dir($dir)) {
respond_error(404, 'Tunnel session not found');
}
return $dir;
}
function ensure_session_alive(string $dir): void
{
if (is_file($dir . DIRECTORY_SEPARATOR . 'error.txt')) {
respond_error(502, trim((string) file_get_contents($dir . DIRECTORY_SEPARATOR . 'error.txt')));
}
if (is_file($dir . DIRECTORY_SEPARATOR . 'closed')) {
respond_error(410, 'Tunnel session closed');
}
}
function ensure_base_dir(): void
{
global $DBX_TUNNEL_DIR;
if (!is_dir($DBX_TUNNEL_DIR) && !mkdir($DBX_TUNNEL_DIR, 0700, true) && !is_dir($DBX_TUNNEL_DIR)) {
respond_error(500, 'Failed to create tunnel base directory');
}
}
function cleanup_old_sessions(): void
{
global $DBX_TUNNEL_DIR, $DBX_TUNNEL_MAX_SESSION_SECONDS;
if (!is_dir($DBX_TUNNEL_DIR)) {
return;
}
foreach (glob(rtrim($DBX_TUNNEL_DIR, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . '*', GLOB_ONLYDIR) ?: [] as $dir) {
if (filemtime($dir) !== false && time() - filemtime($dir) > ($DBX_TUNNEL_MAX_SESSION_SECONDS * 2)) {
remove_dir($dir);
}
}
}
function remove_dir(string $dir): void
{
foreach (glob($dir . DIRECTORY_SEPARATOR . '*') ?: [] as $path) {
if (is_dir($path)) {
remove_dir($path);
} else {
@unlink($path);
}
}
@rmdir($dir);
}
function write_error(string $dir, string $message): void
{
@file_put_contents($dir . DIRECTORY_SEPARATOR . 'error.txt', $message, LOCK_EX);
}
function mark_closed(string $dir): void
{
@touch($dir . DIRECTORY_SEPARATOR . 'closed');
}
function respond_json(array $payload, bool $exit = true): void
{
header('Content-Type: application/json');
header('Cache-Control: no-store');
echo json_encode($payload);
if ($exit) {
exit;
}
}
function respond_error(int $status, string $message): void
{
http_response_code($status);
header('Content-Type: text/plain; charset=utf-8');
header('Cache-Control: no-store');
echo $message;
exit;
}