Skip to content

Commit

Permalink
Merge pull request #10 from clue-labs/fds
Browse files Browse the repository at this point in the history
Improve platform support (chroot environments, Mac and others) and do not inherit open FDs to SSH child process by overwriting and closing
  • Loading branch information
clue authored Jan 20, 2019
2 parents 7ac2c24 + 52d1386 commit f1dc82f
Show file tree
Hide file tree
Showing 7 changed files with 168 additions and 56 deletions.
5 changes: 3 additions & 2 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,13 @@
}
],
"autoload": {
"psr-4": { "Clue\\React\\SshProxy\\": "src/" }
"psr-4": { "Clue\\React\\SshProxy\\": "src/" },
"files": [ "src/Io/functions.php" ]
},
"require": {
"php": ">=5.3",
"clue/socks-react": "^1.0",
"react/child-process": "^0.5",
"react/child-process": "^0.6",
"react/event-loop": "^1.0 || ^0.5",
"react/promise": "^2.1 || ^1.2.1",
"react/socket": "^1.1",
Expand Down
70 changes: 70 additions & 0 deletions src/Io/functions.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
<?php

namespace Clue\React\SshProxy\Io;

use React\ChildProcess\Process;

/**
* Returns a list of active file descriptors (may contain bogus entries)
*
* @param string $path
* @return array
* @internal
*/
function fds($path = '/dev/fd')
{
// Try to get list of all open FDs (Linux/Mac and others)
$fds = @\scandir($path);

// Otherwise try temporarily duplicating file descriptors in the range 0-1024 (FD_SETSIZE).
// This is known to work on more exotic platforms and also inside chroot
// environments without /dev/fd. Causes many syscalls, but still rather fast.
if ($fds === false) {
$fds = array();
for ($i = 0; $i <= 1024; ++$i) {
$copy = @\fopen('php://fd/' . $i, 'r');
if ($copy !== false) {
$fds[] = $i;
\fclose($copy);
}
}
}

return $fds;
}

/**
* Creates a Process with the given command modified in such a way that any additional FDs are explicitly not passed along
*
* @param string $command
* @return Process
* @internal
*/
function processWithoutFds($command)
{
// launch process with default STDIO pipes
$pipes = array(
array('pipe', 'r'),
array('pipe', 'w'),
array('pipe', 'w')
);

// try to get list of all open FDs
$fds = fds();

// do not inherit open FDs by explicitly overwriting existing FDs with dummy files
// additionally, close all dummy files in the child process again
foreach ($fds as $fd) {
if ($fd > 2) {
$pipes[$fd] = array('file', '/dev/null', 'r');
$command .= ' ' . $fd . '>&-';
}
}

// default `sh` only accepts single-digit FDs, so run in bash if needed
if ($fds && max($fds) > 9) {
$command = 'exec bash -c ' . escapeshellarg($command);
}

return new Process($command, null, null, $pipes);
}
22 changes: 1 addition & 21 deletions src/SshProcessConnector.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@
use Clue\React\SshProxy\Io\CompositeConnection;
use Clue\React\SshProxy\Io\LineSeparatedReader;
use React\EventLoop\LoopInterface;
use React\ChildProcess\Process;
use React\Promise\Deferred;
use React\Socket\ConnectorInterface;

Expand Down Expand Up @@ -67,26 +66,7 @@ public function connect($uri)
}

$command = $this->cmd . ' -W ' . \escapeshellarg($parts['host'] . ':' . $parts['port']);

// try to get list of all open FDs (Linux only) or simply assume range 3-1024 (FD_SETSIZE)
$fds = @scandir('/proc/self/fd');
if ($fds === false) {
$fds = range(3, 1024); // @codeCoverageIgnore
}

// do not inherit open FDs by explicitly closing all of them
foreach ($fds as $fd) {
if ($fd > 2) {
$command .= ' ' . $fd . '>&-';
}
}

// default `sh` only accepts single-digit FDs, so run in bash if needed
if ($fds && max($fds) > 9) {
$command = 'exec bash -c ' . escapeshellarg($command);
}

$process = new Process($command);
$process = Io\processWithoutFds($command);
$process->start($this->loop);

$deferred = new Deferred(function () use ($process, $uri) {
Expand Down
22 changes: 1 addition & 21 deletions src/SshSocksConnector.php
Original file line number Diff line number Diff line change
Expand Up @@ -195,27 +195,7 @@ public function idle()

private function listen()
{
$command = $this->cmd;

// try to get list of all open FDs (Linux only) or simply assume range 3-1024 (FD_SETSIZE)
$fds = @scandir('/proc/self/fd');
if ($fds === false) {
$fds = range(3, 1024); // @codeCoverageIgnore
}

// do not inherit open FDs by explicitly closing all of them
foreach ($fds as $fd) {
if ($fd > 2) {
$command .= ' ' . $fd . '>&-';
}
}

// default `sh` only accepts single-digit FDs, so run in bash if needed
if ($fds && max($fds) > 9) {
$command = 'exec bash -c ' . escapeshellarg($command);
}

$process = new Process($command);
$process = Io\processWithoutFds($this->cmd);
$process->start($this->loop);

$deferred = new Deferred(function () use ($process) {
Expand Down
10 changes: 4 additions & 6 deletions tests/FunctionalSshProcessConnectorTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ public function testConnectValidTargetWillReturnPromiseWhichResolvesToConnection
$connection->close();
}

public function testConnectValidTargetWillNotInheritActiveFileDescriptors()
public function testConnectPendingWillNotInheritActiveFileDescriptors()
{
$server = stream_socket_server('tcp://127.0.0.1:0');
$address = stream_socket_get_name($server, false);
Expand All @@ -83,17 +83,15 @@ public function testConnectValidTargetWillNotInheritActiveFileDescriptors()
$this->markTestSkipped('Platform does not prevent binding to same address (Windows?)');
}

// wait for successful connection
$promise = $this->connector->connect('example.com:80');
$connection = \Clue\React\Block\await($promise, $this->loop, self::TIMEOUT);

// close server and ensure we can start a new server on the previous address
// the open SSH connection process should not inherit the existing server socket
// the pending SSH connection process should not inherit the existing server socket
fclose($server);
$server = stream_socket_server('tcp://' . $address);
$this->assertTrue(is_resource($server));

fclose($server);
$connection->close();

$promise->cancel();
}
}
10 changes: 4 additions & 6 deletions tests/FunctionalSshSocksConnectorTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ public function testConnectValidTargetWillReturnPromiseWhichResolvesToConnection
$connection->close();
}

public function testConnectValidTargetWillNotInheritActiveFileDescriptors()
public function testConnectPendingWillNotInheritActiveFileDescriptors()
{
$server = stream_socket_server('tcp://127.0.0.1:0');
$address = stream_socket_get_name($server, false);
Expand All @@ -103,17 +103,15 @@ public function testConnectValidTargetWillNotInheritActiveFileDescriptors()
$this->markTestSkipped('Platform does not prevent binding to same address (Windows?)');
}

// wait for successful connection
$promise = $this->connector->connect('example.com:80');
$connection = \Clue\React\Block\await($promise, $this->loop, self::TIMEOUT);

// close server and ensure we can start a new server on the previous address
// the open SSH connection process should not inherit the existing server socket
// the pending SSH connection process should not inherit the existing server socket
fclose($server);
$server = stream_socket_server('tcp://' . $address);
$this->assertTrue(is_resource($server));

fclose($server);
$connection->close();

$promise->cancel();
}
}
85 changes: 85 additions & 0 deletions tests/Io/FunctionsTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
<?php

use Clue\React\SshProxy\Io;
use PHPUnit\Framework\TestCase;

class FunctionsTest extends TestCase
{
public function testFdsReturnsArray()
{
$fds = Io\fds();

$this->assertInternalType('array', $fds);
}

public function testFdsReturnsArrayWithStdioHandles()
{
// skip when running with closed handles: vendor/bin/phpunit 0<&-
if (!defined('STDIN') || !defined('STDOUT') || !defined('STDERR') || !@fstat(STDIN) || !@fstat(STDOUT) || !@fstat(STDERR)) {
$this->markTestSkipped('Test suite does not appear to run with standard I/O handles');
}

$fds = Io\fds();

$this->assertContains(0, $fds);
$this->assertContains(1, $fds);
$this->assertContains(2, $fds);
}

public function testFdsReturnsSameArrayTwice()
{
$fds = Io\fds();
$second = Io\fds();

$this->assertEquals($fds, $second);
}

public function testFdsWithInvalidPathReturnsArray()
{
$fds = Io\fds('/dev/null');

$this->assertInternalType('array', $fds);
}

public function testFdsWithInvalidPathReturnsSubsetOfFdsFromDevFd()
{
if (@scandir('/dev/fd') === false) {
$this->markTestSkipped('Unable to read /dev/fd');
}

$fds = Io\fds();
$second = Io\fds('/dev/null');

foreach ($second as $one) {
$this->assertContains($one, $fds);
}
}

public function testProcessWithoutFdsReturnsProcessWithoutClosingDefaultHandles()
{
$process = Io\processWithoutFds('sleep 10');

$this->assertInstanceOf('React\ChildProcess\Process', $process);

$this->assertNotContains(' 0>&-', $process->getCommand());
$this->assertNotContains(' 1>&-', $process->getCommand());
$this->assertNotContains(' 2>&-', $process->getCommand());
}

public function testProcessWithoutFdsReturnsProcessWithOriginalCommandPartOfActualCommandWhenDescriptorsNeedToBeClosed()
{
// skip when running with closed handles: vendor/bin/phpunit 0<&-
// bypass for example with dummy handles: vendor/bin/phpunit 8<&-
$fds = Io\fds();
if (!$fds || max($fds) < 3) {
$this->markTestSkipped('Did not detect additional file descriptors to be closed');
}

$process = Io\processWithoutFds('sleep 10');

$this->assertInstanceOf('React\ChildProcess\Process', $process);

$this->assertNotEquals('sleep 10', $process->getCommand());
$this->assertContains('sleep 10', $process->getCommand());
}
}

0 comments on commit f1dc82f

Please sign in to comment.