diff --git a/src/__phutil_library_map__.php b/src/__phutil_library_map__.php index a20af3b96d..c47a8e9513 100644 --- a/src/__phutil_library_map__.php +++ b/src/__phutil_library_map__.php @@ -1746,6 +1746,7 @@ phutil_register_library_map(array( 'PhabricatorRepositoryVCSPassword' => 'applications/repository/storage/PhabricatorRepositoryVCSPassword.php', 'PhabricatorS3FileStorageEngine' => 'applications/files/engine/PhabricatorS3FileStorageEngine.php', 'PhabricatorSQLPatchList' => 'infrastructure/storage/patch/PhabricatorSQLPatchList.php', + 'PhabricatorSSHPassthruCommand' => 'infrastructure/ssh/PhabricatorSSHPassthruCommand.php', 'PhabricatorSSHWorkflow' => 'infrastructure/ssh/PhabricatorSSHWorkflow.php', 'PhabricatorSavedQuery' => 'applications/search/storage/PhabricatorSavedQuery.php', 'PhabricatorSavedQueryQuery' => 'applications/search/query/PhabricatorSavedQueryQuery.php', @@ -4175,6 +4176,7 @@ phutil_register_library_map(array( 'PhabricatorRepositoryTransactionQuery' => 'PhabricatorApplicationTransactionQuery', 'PhabricatorRepositoryVCSPassword' => 'PhabricatorRepositoryDAO', 'PhabricatorS3FileStorageEngine' => 'PhabricatorFileStorageEngine', + 'PhabricatorSSHPassthruCommand' => 'Phobject', 'PhabricatorSSHWorkflow' => 'PhutilArgumentWorkflow', 'PhabricatorSavedQuery' => array( diff --git a/src/applications/diffusion/ssh/DiffusionSSHGitReceivePackWorkflow.php b/src/applications/diffusion/ssh/DiffusionSSHGitReceivePackWorkflow.php index ac148abbe0..4a7ddb34c1 100644 --- a/src/applications/diffusion/ssh/DiffusionSSHGitReceivePackWorkflow.php +++ b/src/applications/diffusion/ssh/DiffusionSSHGitReceivePackWorkflow.php @@ -14,10 +14,6 @@ final class DiffusionSSHGitReceivePackWorkflow )); } - public function isReadOnly() { - return false; - } - public function getRequestPath() { $args = $this->getArgs(); return head($args->getArg('dir')); @@ -25,10 +21,17 @@ final class DiffusionSSHGitReceivePackWorkflow protected function executeRepositoryOperations( PhabricatorRepository $repository) { + + // This is a write, and must have write access. + $this->requireWriteAccess(); + $future = new ExecFuture( 'git-receive-pack %s', $repository->getLocalPath()); - $err = $this->passthruIO($future); + $err = $this->newPassthruCommand() + ->setIOChannel($this->getIOChannel()) + ->setCommandChannelFromExecFuture($future) + ->execute(); if (!$err) { $repository->writeStatusMessage( diff --git a/src/applications/diffusion/ssh/DiffusionSSHGitUploadPackWorkflow.php b/src/applications/diffusion/ssh/DiffusionSSHGitUploadPackWorkflow.php index 915ae45970..3f3c6b4f92 100644 --- a/src/applications/diffusion/ssh/DiffusionSSHGitUploadPackWorkflow.php +++ b/src/applications/diffusion/ssh/DiffusionSSHGitUploadPackWorkflow.php @@ -14,10 +14,6 @@ final class DiffusionSSHGitUploadPackWorkflow )); } - public function isReadOnly() { - return true; - } - public function getRequestPath() { $args = $this->getArgs(); return head($args->getArg('dir')); @@ -28,7 +24,10 @@ final class DiffusionSSHGitUploadPackWorkflow $future = new ExecFuture('git-upload-pack %s', $repository->getLocalPath()); - return $this->passthruIO($future); + return $this->newPassthruCommand() + ->setIOChannel($this->getIOChannel()) + ->setCommandChannelFromExecFuture($future) + ->execute(); } } diff --git a/src/applications/diffusion/ssh/DiffusionSSHWorkflow.php b/src/applications/diffusion/ssh/DiffusionSSHWorkflow.php index 5c2c182c9b..fa015abe6f 100644 --- a/src/applications/diffusion/ssh/DiffusionSSHWorkflow.php +++ b/src/applications/diffusion/ssh/DiffusionSSHWorkflow.php @@ -3,12 +3,17 @@ abstract class DiffusionSSHWorkflow extends PhabricatorSSHWorkflow { private $args; + private $repository; + private $hasWriteAccess; + + public function getRepository() { + return $this->repository; + } public function getArgs() { return $this->args; } - abstract protected function isReadOnly(); abstract protected function getRequestPath(); abstract protected function executeRepositoryOperations( PhabricatorRepository $repository); @@ -23,6 +28,7 @@ abstract class DiffusionSSHWorkflow extends PhabricatorSSHWorkflow { try { $repository = $this->loadRepository(); + $this->repository = $repository; return $this->executeRepositoryOperations($repository); } catch (Exception $ex) { $this->writeError(get_class($ex).': '.$ex->getMessage()); @@ -56,26 +62,11 @@ abstract class DiffusionSSHWorkflow extends PhabricatorSSHWorkflow { pht('No repository "%s" exists!', $callsign)); } - $is_push = !$this->isReadOnly(); - switch ($repository->getServeOverSSH()) { case PhabricatorRepository::SERVE_READONLY: - if ($is_push) { - throw new Exception( - pht('This repository is read-only over SSH.')); - } - break; case PhabricatorRepository::SERVE_READWRITE: - if ($is_push) { - $can_push = PhabricatorPolicyFilter::hasCapability( - $viewer, - $repository, - DiffusionCapabilityPush::CAPABILITY); - if (!$can_push) { - throw new Exception( - pht('You do not have permission to push to this repository.')); - } - } + // If we have read or read/write access, proceed for now. We will + // check write access when the user actually issues a write command. break; case PhabricatorRepository::SERVE_OFF: default: @@ -86,4 +77,40 @@ abstract class DiffusionSSHWorkflow extends PhabricatorSSHWorkflow { return $repository; } + protected function requireWriteAccess() { + if ($this->hasWriteAccess === true) { + return; + } + + $repository = $this->getRepository(); + $viewer = $this->getUser(); + + switch ($repository->getServeOverSSH()) { + case PhabricatorRepository::SERVE_READONLY: + throw new Exception( + pht('This repository is read-only over SSH.')); + break; + case PhabricatorRepository::SERVE_READWRITE: + $can_push = PhabricatorPolicyFilter::hasCapability( + $viewer, + $repository, + DiffusionCapabilityPush::CAPABILITY); + if (!$can_push) { + throw new Exception( + pht('You do not have permission to push to this repository.')); + } + break; + case PhabricatorRepository::SERVE_OFF: + default: + // This shouldn't be reachable because we don't get this far if the + // repository isn't enabled, but kick them out anyway. + throw new Exception( + pht('This repository is not available over SSH.')); + } + + $this->hasWriteAccess = true; + return $this->hasWriteAccess; + } + + } diff --git a/src/infrastructure/ssh/PhabricatorSSHPassthruCommand.php b/src/infrastructure/ssh/PhabricatorSSHPassthruCommand.php new file mode 100644 index 0000000000..e26badae3a --- /dev/null +++ b/src/infrastructure/ssh/PhabricatorSSHPassthruCommand.php @@ -0,0 +1,161 @@ + stdin + * stdout <- stdout + * stderr <- stderr + * + * You can provide **read and write callbacks** which are invoked as data + * is passed through this class. They allow you to inspect and modify traffic. + * + * IO Channel Passthru Command Channel + * stdout -> willWrite -> stdin + * stdin <- willRead <- stdout + * stderr <- (identity) <- stderr + * + * Primarily, this means: + * + * - the **IO Channel** can be a @{class:PhutilProtocolChannel} if the + * **write callback** can convert protocol messages into strings; and + * - the **write callback** can inspect and reject requests over the channel, + * e.g. to enforce policies. + * + * In practice, this is used when serving repositories to check each command + * issued over SSH and determine if it is a read command or a write command. + * Writes can then be checked for appropriate permissions. + */ +final class PhabricatorSSHPassthruCommand extends Phobject { + + private $commandChannel; + private $ioChannel; + private $errorChannel; + private $execFuture; + private $willWriteCallback; + private $willReadCallback; + + public function setCommandChannelFromExecFuture(ExecFuture $exec_future) { + $exec_channel = new PhutilExecChannel($exec_future); + $exec_channel->setStderrHandler(array($this, 'writeErrorIOCallback')); + + $this->execFuture = $exec_future; + $this->commandChannel = $exec_channel; + + return $this; + } + + public function setIOChannel(PhutilChannel $io_channel) { + $this->ioChannel = $io_channel; + return $this; + } + + public function setErrorChannel(PhutilChannel $error_channel) { + $this->errorChannel = $error_channel; + return $this; + } + + public function setWillReadCallback($will_read_callback) { + $this->willReadCallback = $will_read_callback; + return $this; + } + + public function setWillWriteCallback($will_write_callback) { + $this->willWriteCallback = $will_write_callback; + return $this; + } + + public function writeErrorIOCallback(PhutilChannel $channel, $data) { + $this->errorChannel->write($data); + } + + public function execute() { + $command_channel = $this->commandChannel; + $io_channel = $this->ioChannel; + $error_channel = $this->errorChannel; + + if (!$command_channel) { + throw new Exception("Set a command channel before calling execute()!"); + } + + if (!$io_channel) { + throw new Exception("Set an IO channel before calling execute()!"); + } + + if (!$error_channel) { + throw new Exception("Set an error channel before calling execute()!"); + } + + $channels = array($command_channel, $io_channel, $error_channel); + + while (true) { + PhutilChannel::waitForAny($channels); + + $io_channel->update(); + $command_channel->update(); + $error_channel->update(); + + $done = !$command_channel->isOpen(); + + $in_message = $io_channel->read(); + $in_message = $this->willWriteData($in_message); + if ($in_message !== null) { + $command_channel->write($in_message); + } + + $out_message = $command_channel->read(); + $out_message = $this->willReadData($out_message); + if ($out_message !== null) { + $io_channel->write($out_message); + } + + // If we have nothing left on stdin, close stdin on the subprocess. + if (!$io_channel->isOpenForReading()) { + // TODO: This should probably be part of PhutilExecChannel? + $this->execFuture->write(''); + } + + if ($done) { + break; + } + } + + list($err) = $this->execFuture->resolve(); + + return $err; + } + + public function willWriteData($message) { + if ($this->willWriteCallback) { + return call_user_func($this->willWriteCallback, $this, $message); + } else { + if (strlen($message)) { + return $message; + } else { + return null; + } + } + } + + public function willReadData($message) { + if ($this->willReadCallback) { + return call_user_func($this->willReadCallback, $this, $message); + } else { + if (strlen($message)) { + return $message; + } else { + return null; + } + } + } + +} diff --git a/src/infrastructure/ssh/PhabricatorSSHWorkflow.php b/src/infrastructure/ssh/PhabricatorSSHWorkflow.php index 60a9f03fcd..7dbbc579d3 100644 --- a/src/infrastructure/ssh/PhabricatorSSHWorkflow.php +++ b/src/infrastructure/ssh/PhabricatorSSHWorkflow.php @@ -37,50 +37,6 @@ abstract class PhabricatorSSHWorkflow extends PhutilArgumentWorkflow { return $this->iochannel; } - public function passthruIO(ExecFuture $future) { - $exec_channel = new PhutilExecChannel($future); - $exec_channel->setStderrHandler(array($this, 'writeErrorIOCallback')); - - $io_channel = $this->getIOChannel(); - $error_channel = $this->getErrorChannel(); - - $channels = array($exec_channel, $io_channel, $error_channel); - - while (true) { - PhutilChannel::waitForAny($channels); - - $io_channel->update(); - $exec_channel->update(); - $error_channel->update(); - - $done = !$exec_channel->isOpen(); - - $data = $io_channel->read(); - if (strlen($data)) { - $exec_channel->write($data); - } - - $data = $exec_channel->read(); - if (strlen($data)) { - $io_channel->write($data); - } - - // If we have nothing left on stdin, close stdin on the subprocess. - if (!$io_channel->isOpenForReading()) { - // TODO: This should probably be part of PhutilExecChannel? - $future->write(''); - } - - if ($done) { - break; - } - } - - list($err) = $future->resolve(); - - return $err; - } - public function readAllInput() { $channel = $this->getIOChannel(); while ($channel->update()) { @@ -102,8 +58,9 @@ abstract class PhabricatorSSHWorkflow extends PhutilArgumentWorkflow { return $this; } - public function writeErrorIOCallback(PhutilChannel $channel, $data) { - $this->writeErrorIO($data); + protected function newPassthruCommand() { + return id(new PhabricatorSSHPassthruCommand()) + ->setErrorChannel($this->getErrorChannel()); } }