diff --git a/src/applications/diffusion/ssh/DiffusionGitSSHWorkflow.php b/src/applications/diffusion/ssh/DiffusionGitSSHWorkflow.php index 52a8478fdc..6de16e723d 100644 --- a/src/applications/diffusion/ssh/DiffusionGitSSHWorkflow.php +++ b/src/applications/diffusion/ssh/DiffusionGitSSHWorkflow.php @@ -1,38 +1,48 @@ getErrorChannel()->update(); } protected function identifyRepository() { $args = $this->getArgs(); $path = head($args->getArg('dir')); return $this->loadRepositoryWithPath( $path, PhabricatorRepositoryType::REPOSITORY_TYPE_GIT); } protected function waitForGitClient() { $io_channel = $this->getIOChannel(); // If we don't wait for the client to close the connection, `git` will // consider it an early abort and fail. Sit around until Git is comfortable // that it really received all the data. while ($io_channel->isOpenForReading()) { $io_channel->update(); $this->getErrorChannel()->flush(); PhutilChannel::waitForAny(array($io_channel)); } } + protected function raiseWrongVCSException( + PhabricatorRepository $repository) { + throw new Exception( + pht( + 'This repository ("%s") is not a Git repository. Use "%s" to '. + 'interact with this repository.', + $repository->getDisplayName(), + $repository->getVersionControlSystem())); + } + } diff --git a/src/applications/diffusion/ssh/DiffusionMercurialServeSSHWorkflow.php b/src/applications/diffusion/ssh/DiffusionMercurialServeSSHWorkflow.php index 15dd9d7c6b..4508ae53da 100644 --- a/src/applications/diffusion/ssh/DiffusionMercurialServeSSHWorkflow.php +++ b/src/applications/diffusion/ssh/DiffusionMercurialServeSSHWorkflow.php @@ -1,122 +1,132 @@ setName('hg'); $this->setArguments( array( array( 'name' => 'repository', 'short' => 'R', 'param' => 'repo', ), array( 'name' => 'stdio', ), array( 'name' => 'command', 'wildcard' => true, ), )); } protected function identifyRepository() { $args = $this->getArgs(); $path = $args->getArg('repository'); return $this->loadRepositoryWithPath( $path, PhabricatorRepositoryType::REPOSITORY_TYPE_MERCURIAL); } protected function executeRepositoryOperations() { $repository = $this->getRepository(); $args = $this->getArgs(); if (!$args->getArg('stdio')) { throw new Exception(pht('Expected `%s`!', 'hg ... --stdio')); } if ($args->getArg('command') !== array('serve')) { throw new Exception(pht('Expected `%s`!', 'hg ... serve')); } if ($this->shouldProxy()) { $command = $this->getProxyCommand(); } else { $command = csprintf( 'hg -R %s serve --stdio', $repository->getLocalPath()); } $command = PhabricatorDaemon::sudoCommandAsDaemonUser($command); $future = id(new ExecFuture('%C', $command)) ->setEnv($this->getEnvironment()); $io_channel = $this->getIOChannel(); $protocol_channel = new DiffusionMercurialWireClientSSHProtocolChannel( $io_channel); $err = id($this->newPassthruCommand()) ->setIOChannel($protocol_channel) ->setCommandChannelFromExecFuture($future) ->setWillWriteCallback(array($this, 'willWriteMessageCallback')) ->execute(); // TODO: It's apparently technically possible to communicate errors to // Mercurial over SSH by writing a special "\n\n-\n" string. However, // my attempt to implement that resulted in Mercurial closing the socket and // then hanging, without showing the error. This might be an issue on our // side (we need to close our half of the socket?), or maybe the code // for this in Mercurial doesn't actually work, or maybe something else // is afoot. At some point, we should look into doing this more cleanly. // For now, when we, e.g., reject writes for policy reasons, the user will // see "abort: unexpected response: empty string" after the diagnostically // useful, e.g., "remote: This repository is read-only over SSH." message. if (!$err && $this->didSeeWrite) { $repository->writeStatusMessage( PhabricatorRepositoryStatusMessage::TYPE_NEEDS_UPDATE, PhabricatorRepositoryStatusMessage::CODE_OKAY); } return $err; } public function willWriteMessageCallback( PhabricatorSSHPassthruCommand $command, $message) { $command = $message['command']; // Check if this is a readonly command. $is_readonly = false; if ($command == 'batch') { $cmds = idx($message['arguments'], 'cmds'); if (DiffusionMercurialWireProtocol::isReadOnlyBatchCommand($cmds)) { $is_readonly = true; } } else if (DiffusionMercurialWireProtocol::isReadOnlyCommand($command)) { $is_readonly = true; } if (!$is_readonly) { $this->requireWriteAccess(); $this->didSeeWrite = true; } $raw_message = $message['raw']; if ($command == 'capabilities') { $raw_message = DiffusionMercurialWireProtocol::filterBundle2Capability( $raw_message); } // If we're good, return the raw message data. return $raw_message; } + protected function raiseWrongVCSException( + PhabricatorRepository $repository) { + throw new Exception( + pht( + 'This repository ("%s") is not a Mercurial repository. Use "%s" to '. + 'interact with this repository.', + $repository->getDisplayName(), + $repository->getVersionControlSystem())); + } + } diff --git a/src/applications/diffusion/ssh/DiffusionSSHWorkflow.php b/src/applications/diffusion/ssh/DiffusionSSHWorkflow.php index 0115858832..0e5ad7bbe1 100644 --- a/src/applications/diffusion/ssh/DiffusionSSHWorkflow.php +++ b/src/applications/diffusion/ssh/DiffusionSSHWorkflow.php @@ -1,274 +1,280 @@ repository) { throw new Exception(pht('Repository is not available yet!')); } return $this->repository; } private function setRepository(PhabricatorRepository $repository) { $this->repository = $repository; return $this; } public function getArgs() { return $this->args; } public function getEnvironment() { $env = array( DiffusionCommitHookEngine::ENV_USER => $this->getUser()->getUsername(), DiffusionCommitHookEngine::ENV_REMOTE_PROTOCOL => 'ssh', ); $remote_address = $this->getSSHRemoteAddress(); if ($remote_address !== null) { $env[DiffusionCommitHookEngine::ENV_REMOTE_ADDRESS] = $remote_address; } return $env; } /** * Identify and load the affected repository. */ abstract protected function identifyRepository(); abstract protected function executeRepositoryOperations(); + abstract protected function raiseWrongVCSException( + PhabricatorRepository $repository); protected function getBaseRequestPath() { return $this->baseRequestPath; } protected function writeError($message) { $this->getErrorChannel()->write($message); return $this; } protected function getCurrentDeviceName() { $device = AlmanacKeys::getLiveDevice(); if ($device) { return $device->getName(); } return php_uname('n'); } protected function getTargetDeviceName() { // TODO: This should use the correct device identity. $uri = new PhutilURI($this->proxyURI); return $uri->getDomain(); } protected function shouldProxy() { return (bool)$this->proxyURI; } protected function getProxyCommand() { $uri = new PhutilURI($this->proxyURI); $username = AlmanacKeys::getClusterSSHUser(); if ($username === null) { throw new Exception( pht( 'Unable to determine the username to connect with when trying '. 'to proxy an SSH request within the Phabricator cluster.')); } $port = $uri->getPort(); $host = $uri->getDomain(); $key_path = AlmanacKeys::getKeyPath('device.key'); if (!Filesystem::pathExists($key_path)) { throw new Exception( pht( 'Unable to proxy this SSH request within the cluster: this device '. 'is not registered and has a missing device key (expected to '. 'find key at "%s").', $key_path)); } $options = array(); $options[] = '-o'; $options[] = 'StrictHostKeyChecking=no'; $options[] = '-o'; $options[] = 'UserKnownHostsFile=/dev/null'; // This is suppressing "added
to the list of known hosts" // messages, which are confusing and irrelevant when they arise from // proxied requests. It might also be suppressing lots of useful errors, // of course. Ideally, we would enforce host keys eventually. $options[] = '-o'; $options[] = 'LogLevel=quiet'; // NOTE: We prefix the command with "@username", which the far end of the // connection will parse in order to act as the specified user. This // behavior is only available to cluster requests signed by a trusted // device key. return csprintf( 'ssh %Ls -l %s -i %s -p %s %s -- %s %Ls', $options, $username, $key_path, $port, $host, '@'.$this->getUser()->getUsername(), $this->getOriginalArguments()); } final public function execute(PhutilArgumentParser $args) { $this->args = $args; $viewer = $this->getUser(); $have_diffusion = PhabricatorApplication::isClassInstalledForViewer( 'PhabricatorDiffusionApplication', $viewer); if (!$have_diffusion) { throw new Exception( pht( 'You do not have permission to access the Diffusion application, '. 'so you can not interact with repositories over SSH.')); } $repository = $this->identifyRepository(); $this->setRepository($repository); $is_cluster_request = $this->getIsClusterRequest(); $uri = $repository->getAlmanacServiceURI( $viewer, $is_cluster_request, array( 'ssh', )); if ($uri) { $this->proxyURI = $uri; } try { return $this->executeRepositoryOperations(); } catch (Exception $ex) { $this->writeError(get_class($ex).': '.$ex->getMessage()); return 1; } } protected function loadRepositoryWithPath($path, $vcs) { $viewer = $this->getUser(); $info = PhabricatorRepository::parseRepositoryServicePath($path, $vcs); if ($info === null) { throw new Exception( pht( 'Unrecognized repository path "%s". Expected a path like "%s", '. '"%s", or "%s".', $path, '/diffusion/X/', '/diffusion/123/', '/source/thaumaturgy.git')); } $identifier = $info['identifier']; $base = $info['base']; $this->baseRequestPath = $base; $repository = id(new PhabricatorRepositoryQuery()) ->setViewer($viewer) ->withIdentifiers(array($identifier)) ->needURIs(true) ->executeOne(); if (!$repository) { throw new Exception( pht('No repository "%s" exists!', $identifier)); } $protocol = PhabricatorRepositoryURI::BUILTIN_PROTOCOL_SSH; if (!$repository->canServeProtocol($protocol, false)) { throw new Exception( pht( 'This repository ("%s") is not available over SSH.', $repository->getDisplayName())); } + if ($repository->getVersionControlSystem() != $vcs) { + $this->raiseWrongVCSException($repository); + } + return $repository; } protected function requireWriteAccess($protocol_command = null) { if ($this->hasWriteAccess === true) { return; } $repository = $this->getRepository(); $viewer = $this->getUser(); if ($viewer->isOmnipotent()) { throw new Exception( pht( 'This request is authenticated as a cluster device, but is '. 'performing a write. Writes must be performed with a real '. 'user account.')); } $protocol = PhabricatorRepositoryURI::BUILTIN_PROTOCOL_SSH; if ($repository->canServeProtocol($protocol, true)) { $can_push = PhabricatorPolicyFilter::hasCapability( $viewer, $repository, DiffusionPushCapability::CAPABILITY); if (!$can_push) { throw new Exception( pht('You do not have permission to push to this repository.')); } } else { if ($protocol_command !== null) { throw new Exception( pht( 'This repository is read-only over SSH (tried to execute '. 'protocol command "%s").', $protocol_command)); } else { throw new Exception( pht('This repository is read-only over SSH.')); } } $this->hasWriteAccess = true; return $this->hasWriteAccess; } protected function shouldSkipReadSynchronization() { $viewer = $this->getUser(); // Currently, the only case where devices interact over SSH without // assuming user credentials is when synchronizing before a read. These // synchronizing reads do not themselves need to be synchronized. if ($viewer->isOmnipotent()) { return true; } return false; } protected function newPullEvent() { $viewer = $this->getViewer(); $repository = $this->getRepository(); $remote_address = $this->getSSHRemoteAddress(); return id(new PhabricatorRepositoryPullEvent()) ->setEpoch(PhabricatorTime::getNow()) ->setRemoteAddress($remote_address) ->setRemoteProtocol('ssh') ->setPullerPHID($viewer->getPHID()) ->setRepositoryPHID($repository->getPHID()); } } diff --git a/src/applications/diffusion/ssh/DiffusionSubversionServeSSHWorkflow.php b/src/applications/diffusion/ssh/DiffusionSubversionServeSSHWorkflow.php index 3a8b516443..f2a932f2bb 100644 --- a/src/applications/diffusion/ssh/DiffusionSubversionServeSSHWorkflow.php +++ b/src/applications/diffusion/ssh/DiffusionSubversionServeSSHWorkflow.php @@ -1,452 +1,462 @@ command; } protected function didConstruct() { $this->setName('svnserve'); $this->setArguments( array( array( 'name' => 'tunnel', 'short' => 't', ), )); } protected function identifyRepository() { // NOTE: In SVN, we need to read the first few protocol frames before we // can determine which repository the user is trying to access. We're // going to peek at the data on the wire to identify the repository. $io_channel = $this->getIOChannel(); // Before the client will send us the first protocol frame, we need to send // it a connection frame with server capabilities. To figure out the // correct frame we're going to start `svnserve`, read the frame from it, // send it to the client, then kill the subprocess. // TODO: This is pretty inelegant and the protocol frame will change very // rarely. We could cache it if we can find a reasonable way to dirty the // cache. $command = csprintf('svnserve -t'); $command = PhabricatorDaemon::sudoCommandAsDaemonUser($command); $future = new ExecFuture('%C', $command); $exec_channel = new PhutilExecChannel($future); $exec_protocol = new DiffusionSubversionWireProtocol(); while (true) { PhutilChannel::waitForAny(array($exec_channel)); $exec_channel->update(); $exec_message = $exec_channel->read(); if ($exec_message !== null) { $messages = $exec_protocol->writeData($exec_message); if ($messages) { $message = head($messages); $raw = $message['raw']; // Write the greeting frame to the client. $io_channel->write($raw); // Kill the subprocess. $future->resolveKill(); break; } } if (!$exec_channel->isOpenForReading()) { throw new Exception( pht( '%s subprocess exited before emitting a protocol frame.', 'svnserve')); } } $io_protocol = new DiffusionSubversionWireProtocol(); while (true) { PhutilChannel::waitForAny(array($io_channel)); $io_channel->update(); $in_message = $io_channel->read(); if ($in_message !== null) { $this->peekBuffer .= $in_message; if (strlen($this->peekBuffer) > (1024 * 1024)) { throw new Exception( pht( 'Client transmitted more than 1MB of data without transmitting '. 'a recognizable protocol frame.')); } $messages = $io_protocol->writeData($in_message); if ($messages) { $message = head($messages); $struct = $message['structure']; // This is the: // // ( version ( cap1 ... ) url ... ) // // The `url` allows us to identify the repository. $uri = $struct[2]['value']; $path = $this->getPathFromSubversionURI($uri); return $this->loadRepositoryWithPath( $path, PhabricatorRepositoryType::REPOSITORY_TYPE_SVN); } } if (!$io_channel->isOpenForReading()) { throw new Exception( pht( 'Client closed connection before sending a complete protocol '. 'frame.')); } // If the client has disconnected, kill the subprocess and bail. if (!$io_channel->isOpenForWriting()) { throw new Exception( pht( 'Client closed connection before receiving response.')); } } } protected function executeRepositoryOperations() { $repository = $this->getRepository(); $args = $this->getArgs(); if (!$args->getArg('tunnel')) { throw new Exception(pht('Expected `%s`!', 'svnserve -t')); } if ($this->shouldProxy()) { $command = $this->getProxyCommand(); $this->isProxying = true; $cwd = null; } else { $command = csprintf( 'svnserve -t --tunnel-user=%s', $this->getUser()->getUsername()); $cwd = PhabricatorEnv::getEmptyCWD(); } $command = PhabricatorDaemon::sudoCommandAsDaemonUser($command); $future = new ExecFuture('%C', $command); // If we're receiving a commit, svnserve will fail to execute the commit // hook with an unhelpful error if the CWD isn't readable by the user we // are sudoing to. Switch to a readable, empty CWD before running // svnserve. See T10941. if ($cwd !== null) { $future->setCWD($cwd); } $this->inProtocol = new DiffusionSubversionWireProtocol(); $this->outProtocol = new DiffusionSubversionWireProtocol(); $this->command = id($this->newPassthruCommand()) ->setIOChannel($this->getIOChannel()) ->setCommandChannelFromExecFuture($future) ->setWillWriteCallback(array($this, 'willWriteMessageCallback')) ->setWillReadCallback(array($this, 'willReadMessageCallback')); $this->command->setPauseIOReads(true); $err = $this->command->execute(); if (!$err && $this->didSeeWrite) { $this->getRepository()->writeStatusMessage( PhabricatorRepositoryStatusMessage::TYPE_NEEDS_UPDATE, PhabricatorRepositoryStatusMessage::CODE_OKAY); } return $err; } public function willWriteMessageCallback( PhabricatorSSHPassthruCommand $command, $message) { $proto = $this->inProtocol; $messages = $proto->writeData($message); $result = array(); foreach ($messages as $message) { $message_raw = $message['raw']; $struct = $message['structure']; if (!$this->inSeenGreeting) { $this->inSeenGreeting = true; // The first message the client sends looks like: // // ( version ( cap1 ... ) url ... ) // // We want to grab the URL, load the repository, make sure it exists and // is accessible, and then replace it with the location of the // repository on disk. $uri = $struct[2]['value']; $struct[2]['value'] = $this->makeInternalURI($uri); $message_raw = $proto->serializeStruct($struct); } else if (isset($struct[0]) && $struct[0]['type'] == 'word') { if (!$proto->isReadOnlyCommand($struct)) { $this->didSeeWrite = true; $this->requireWriteAccess($struct[0]['value']); } // Several other commands also pass in URLs. We need to translate // all of these into the internal representation; this also makes sure // they're valid and accessible. switch ($struct[0]['value']) { case 'reparent': // ( reparent ( url ) ) $struct[1]['value'][0]['value'] = $this->makeInternalURI( $struct[1]['value'][0]['value']); $message_raw = $proto->serializeStruct($struct); break; case 'switch': // ( switch ( ( rev ) target recurse url ... ) ) $struct[1]['value'][3]['value'] = $this->makeInternalURI( $struct[1]['value'][3]['value']); $message_raw = $proto->serializeStruct($struct); break; case 'diff': // ( diff ( ( rev ) target recurse ignore-ancestry url ... ) ) $struct[1]['value'][4]['value'] = $this->makeInternalURI( $struct[1]['value'][4]['value']); $message_raw = $proto->serializeStruct($struct); break; case 'add-file': case 'add-dir': // ( add-file ( path dir-token file-token [ copy-path copy-rev ] ) ) // ( add-dir ( path parent child [ copy-path copy-rev ] ) ) if (isset($struct[1]['value'][3]['value'][0]['value'])) { $copy_from = $struct[1]['value'][3]['value'][0]['value']; $copy_from = $this->makeInternalURI($copy_from); $struct[1]['value'][3]['value'][0]['value'] = $copy_from; } $message_raw = $proto->serializeStruct($struct); break; } } $result[] = $message_raw; } if (!$result) { return null; } return implode('', $result); } public function willReadMessageCallback( PhabricatorSSHPassthruCommand $command, $message) { $proto = $this->outProtocol; $messages = $proto->writeData($message); $result = array(); foreach ($messages as $message) { $message_raw = $message['raw']; $struct = $message['structure']; if (isset($struct[0]) && ($struct[0]['type'] == 'word')) { if ($struct[0]['value'] == 'success') { switch ($this->outPhaseCount) { case 0: // This is the "greeting", which announces capabilities. // We already sent this when we were figuring out which // repository this request is for, so we aren't going to send // it again. // Instead, we're going to replay the client's response (which // we also already read). $command = $this->getCommand(); $command->writeIORead($this->peekBuffer); $command->setPauseIOReads(false); $message_raw = null; break; case 1: // This responds to the client greeting, and announces auth. break; case 2: // This responds to auth, which should be trivial over SSH. break; case 3: // This contains the URI of the repository. We need to edit it; // if it does not match what the client requested it will reject // the response. $struct[1]['value'][1]['value'] = $this->makeExternalURI( $struct[1]['value'][1]['value']); $message_raw = $proto->serializeStruct($struct); break; default: // We don't care about other protocol frames. break; } $this->outPhaseCount++; } else if ($struct[0]['value'] == 'failure') { // Find any error messages which include the internal URI, and // replace the text with the external URI. foreach ($struct[1]['value'] as $key => $error) { $code = $error['value'][0]['value']; $message = $error['value'][1]['value']; $message = str_replace( $this->internalBaseURI, $this->externalBaseURI, $message); // Derp derp derp derp derp. The structure looks like this: // ( failure ( ( code message ... ) ... ) ) $struct[1]['value'][$key]['value'][1]['value'] = $message; } $message_raw = $proto->serializeStruct($struct); } } if ($message_raw !== null) { $result[] = $message_raw; } } if (!$result) { return null; } return implode('', $result); } private function getPathFromSubversionURI($uri_string) { $uri = new PhutilURI($uri_string); $proto = $uri->getProtocol(); if ($proto !== 'svn+ssh') { throw new Exception( pht( 'Protocol for URI "%s" MUST be "%s".', $uri_string, 'svn+ssh')); } $path = $uri->getPath(); // Subversion presumably deals with this, but make sure there's nothing // sketchy going on with the URI. if (preg_match('(/\\.\\./)', $path)) { throw new Exception( pht( 'String "%s" is invalid in path specification "%s".', '/../', $uri_string)); } $path = $this->normalizeSVNPath($path); return $path; } private function makeInternalURI($uri_string) { if ($this->isProxying) { return $uri_string; } $uri = new PhutilURI($uri_string); $repository = $this->getRepository(); $path = $this->getPathFromSubversionURI($uri_string); $external_base = $this->getBaseRequestPath(); // Replace "/diffusion/X" in the request with the repository local path, // so "/diffusion/X/master/" becomes "/path/to/repository/X/master/". $local_path = rtrim($repository->getLocalPath(), '/'); $path = $local_path.substr($path, strlen($external_base)); // NOTE: We are intentionally NOT removing username information from the // URI. Subversion retains it over the course of the request and considers // two repositories with different username identifiers to be distinct and // incompatible. $uri->setPath($path); // If this is happening during the handshake, these are the base URIs for // the request. if ($this->externalBaseURI === null) { $pre = (string)id(clone $uri)->setPath(''); $external_path = $external_base; $external_path = $this->normalizeSVNPath($external_path); $this->externalBaseURI = $pre.$external_path; $internal_path = rtrim($repository->getLocalPath(), '/'); $internal_path = $this->normalizeSVNPath($internal_path); $this->internalBaseURI = $pre.$internal_path; } return (string)$uri; } private function makeExternalURI($uri) { if ($this->isProxying) { return $uri; } $internal = $this->internalBaseURI; $external = $this->externalBaseURI; if (strncmp($uri, $internal, strlen($internal)) === 0) { $uri = $external.substr($uri, strlen($internal)); } return $uri; } private function normalizeSVNPath($path) { // Subversion normalizes redundant slashes internally, so normalize them // here as well to make sure things match up. $path = preg_replace('(/+)', '/', $path); return $path; } + protected function raiseWrongVCSException( + PhabricatorRepository $repository) { + throw new Exception( + pht( + 'This repository ("%s") is not a Subversion repository. Use "%s" to '. + 'interact with this repository.', + $repository->getDisplayName(), + $repository->getVersionControlSystem())); + } + }