diff --git a/src/applications/config/option/PhabricatorPHDConfigOptions.php b/src/applications/config/option/PhabricatorPHDConfigOptions.php index ae3f7f52b7..934684b91b 100644 --- a/src/applications/config/option/PhabricatorPHDConfigOptions.php +++ b/src/applications/config/option/PhabricatorPHDConfigOptions.php @@ -1,60 +1,68 @@ newOption('phd.pid-directory', 'string', '/var/tmp/phd/pid') ->setDescription( pht( "Directory that phd should use to track running daemons.")), $this->newOption('phd.log-directory', 'string', '/var/tmp/phd/log') ->setDescription( pht( "Directory that the daemons should use to store log files.")), $this->newOption('phd.start-taskmasters', 'int', 4) ->setSummary(pht("Number of TaskMaster daemons to start by default.")) ->setDescription( pht( "Number of 'TaskMaster' daemons that 'phd start' should start. ". "You can raise this if you have a task backlog, or explicitly ". "launch more with 'phd launch taskmaster'.")), $this->newOption('phd.verbose', 'bool', false) ->setBoolOptions( array( pht("Verbose mode"), pht("Normal mode"), )) ->setSummary(pht("Launch daemons in 'verbose' mode by default.")) ->setDescription( pht( "Launch daemons in 'verbose' mode by default. This creates a lot ". "of output, but can help debug issues. Daemons launched in debug ". "mode with 'phd debug' are always launched in verbose mode. See ". "also 'phd.trace'.")), + $this->newOption('phd.user', 'string', null) + ->setSummary(pht("System user to run daemons as.")) + ->setDescription( + pht( + "Specify a system user to run the daemons as. Primarily, this ". + "user will own the working copies of any repositories that ". + "Phabricator imports or manages. This option is new and ". + "experimental.")), $this->newOption('phd.trace', 'bool', false) ->setBoolOptions( array( pht("Trace mode"), pht("Normal mode"), )) ->setSummary(pht("Launch daemons in 'trace' mode by default.")) ->setDescription( pht( "Launch daemons in 'trace' mode by default. This creates an ". "ENORMOUS amount of output, but can help debug issues. Daemons ". "launched in debug mode with 'phd debug' are always launched in ". "trace mdoe. See also 'phd.verbose'.")), ); } } diff --git a/src/applications/diffusion/controller/DiffusionServeController.php b/src/applications/diffusion/controller/DiffusionServeController.php index c009fbe83e..7bb19e47ce 100644 --- a/src/applications/diffusion/controller/DiffusionServeController.php +++ b/src/applications/diffusion/controller/DiffusionServeController.php @@ -1,549 +1,556 @@ getHTTPHeader('Content-Type'); $user_agent = idx($_SERVER, 'HTTP_USER_AGENT'); $vcs = null; if ($request->getExists('service')) { $service = $request->getStr('service'); // We get this initially for `info/refs`. // Git also gives us a User-Agent like "git/1.8.2.3". $vcs = PhabricatorRepositoryType::REPOSITORY_TYPE_GIT; } else if (strncmp($user_agent, "git/", 4) === 0) { $vcs = PhabricatorRepositoryType::REPOSITORY_TYPE_GIT; } else if ($content_type == 'application/x-git-upload-pack-request') { // We get this for `git-upload-pack`. $vcs = PhabricatorRepositoryType::REPOSITORY_TYPE_GIT; } else if ($content_type == 'application/x-git-receive-pack-request') { // We get this for `git-receive-pack`. $vcs = PhabricatorRepositoryType::REPOSITORY_TYPE_GIT; } else if ($request->getExists('cmd')) { // Mercurial also sends an Accept header like // "application/mercurial-0.1", and a User-Agent like // "mercurial/proto-1.0". $vcs = PhabricatorRepositoryType::REPOSITORY_TYPE_MERCURIAL; } else { // Subversion also sends an initial OPTIONS request (vs GET/POST), and // has a User-Agent like "SVN/1.8.3 (x86_64-apple-darwin11.4.2) // serf/1.3.2". $dav = $request->getHTTPHeader('DAV'); $dav = new PhutilURI($dav); if ($dav->getDomain() === 'subversion.tigris.org') { $vcs = PhabricatorRepositoryType::REPOSITORY_TYPE_SVN; } } return $vcs; } private static function getCallsign(AphrontRequest $request) { $uri = $request->getRequestURI(); $regex = '@^/diffusion/(?P[A-Z]+)(/|$)@'; $matches = null; if (!preg_match($regex, (string)$uri, $matches)) { return null; } return $matches['callsign']; } public function processRequest() { $request = $this->getRequest(); $callsign = self::getCallsign($request); // If authentication credentials have been provided, try to find a user // that actually matches those credentials. if (isset($_SERVER['PHP_AUTH_USER']) && isset($_SERVER['PHP_AUTH_PW'])) { $username = $_SERVER['PHP_AUTH_USER']; $password = new PhutilOpaqueEnvelope($_SERVER['PHP_AUTH_PW']); $viewer = $this->authenticateHTTPRepositoryUser($username, $password); if (!$viewer) { return new PhabricatorVCSResponse( 403, pht('Invalid credentials.')); } } else { // User hasn't provided credentials, which means we count them as // being "not logged in". $viewer = new PhabricatorUser(); } $allow_public = PhabricatorEnv::getEnvConfig('policy.allow-public'); $allow_auth = PhabricatorEnv::getEnvConfig('diffusion.allow-http-auth'); if (!$allow_public) { if (!$viewer->isLoggedIn()) { if ($allow_auth) { return new PhabricatorVCSResponse( 401, pht('You must log in to access repositories.')); } else { return new PhabricatorVCSResponse( 403, pht('Public and authenticated HTTP access are both forbidden.')); } } } try { $repository = id(new PhabricatorRepositoryQuery()) ->setViewer($viewer) ->withCallsigns(array($callsign)) ->executeOne(); if (!$repository) { return new PhabricatorVCSResponse( 404, pht('No such repository exists.')); } } catch (PhabricatorPolicyException $ex) { if ($viewer->isLoggedIn()) { return new PhabricatorVCSResponse( 403, pht('You do not have permission to access this repository.')); } else { if ($allow_auth) { return new PhabricatorVCSResponse( 401, pht('You must log in to access this repository.')); } else { return new PhabricatorVCSResponse( 403, pht( 'This repository requires authentication, which is forbidden '. 'over HTTP.')); } } } if (!$repository->isTracked()) { return new PhabricatorVCSResponse( 403, pht('This repository is inactive.')); } $is_push = !$this->isReadOnlyRequest($repository); switch ($repository->getServeOverHTTP()) { case PhabricatorRepository::SERVE_READONLY: if ($is_push) { return new PhabricatorVCSResponse( 403, pht('This repository is read-only over HTTP.')); } break; case PhabricatorRepository::SERVE_READWRITE: if ($is_push) { $can_push = PhabricatorPolicyFilter::hasCapability( $viewer, $repository, DiffusionCapabilityPush::CAPABILITY); if (!$can_push) { if ($viewer->isLoggedIn()) { return new PhabricatorVCSResponse( 403, pht('You do not have permission to push to this repository.')); } else { if ($allow_auth) { return new PhabricatorVCSResponse( 401, pht('You must log in to push to this repository.')); } else { return new PhabricatorVCSResponse( 403, pht( 'Pushing to this repository requires authentication, '. 'which is forbidden over HTTP.')); } } } } break; case PhabricatorRepository::SERVE_OFF: default: return new PhabricatorVCSResponse( 403, pht('This repository is not available over HTTP.')); } $vcs_type = $repository->getVersionControlSystem(); $req_type = $this->isVCSRequest($request); if ($vcs_type != $req_type) { switch ($req_type) { case PhabricatorRepositoryType::REPOSITORY_TYPE_GIT: $result = new PhabricatorVCSResponse( 500, pht('This is not a Git repository.')); break; case PhabricatorRepositoryType::REPOSITORY_TYPE_MERCURIAL: $result = new PhabricatorVCSResponse( 500, pht('This is not a Mercurial repository.')); break; case PhabricatorRepositoryType::REPOSITORY_TYPE_SVN: $result = new PhabricatorVCSResponse( 500, pht('This is not a Subversion repository.')); break; default: $result = new PhabricatorVCSResponse( 500, pht('Unknown request type.')); break; } } else { switch ($vcs_type) { case PhabricatorRepositoryType::REPOSITORY_TYPE_GIT: $result = $this->serveGitRequest($repository, $viewer); break; case PhabricatorRepositoryType::REPOSITORY_TYPE_MERCURIAL: $result = $this->serveMercurialRequest($repository, $viewer); break; case PhabricatorRepositoryType::REPOSITORY_TYPE_SVN: $result = new PhabricatorVCSResponse( 500, pht( 'Phabricator does not support HTTP access to Subversion '. 'repositories.')); break; default: $result = new PhabricatorVCSResponse( 500, pht('Unknown version control system.')); break; } } $code = $result->getHTTPResponseCode(); if ($is_push && ($code == 200)) { $unguarded = AphrontWriteGuard::beginScopedUnguardedWrites(); $repository->writeStatusMessage( PhabricatorRepositoryStatusMessage::TYPE_NEEDS_UPDATE, PhabricatorRepositoryStatusMessage::CODE_OKAY); unset($unguarded); } return $result; } private function isReadOnlyRequest( PhabricatorRepository $repository) { $request = $this->getRequest(); $method = $_SERVER['REQUEST_METHOD']; // TODO: This implementation is safe by default, but very incomplete. switch ($repository->getVersionControlSystem()) { case PhabricatorRepositoryType::REPOSITORY_TYPE_GIT: $service = $request->getStr('service'); $path = $this->getRequestDirectoryPath(); // NOTE: Service names are the reverse of what you might expect, as they // are from the point of view of the server. The main read service is // "git-upload-pack", and the main write service is "git-receive-pack". if ($method == 'GET' && $path == '/info/refs' && $service == 'git-upload-pack') { return true; } if ($path == '/git-upload-pack') { return true; } break; case PhabricatorRepositoryType::REPOSITORY_TYPE_MERCURIAL: $cmd = $request->getStr('cmd'); if ($cmd == 'batch') { $cmds = idx($this->getMercurialArguments(), 'cmds'); return DiffusionMercurialWireProtocol::isReadOnlyBatchCommand($cmds); } return DiffusionMercurialWireProtocol::isReadOnlyCommand($cmd); case PhabricatorRepositoryType::REPOSITORY_TYPE_SVN: break; } return false; } /** * @phutil-external-symbol class PhabricatorStartup */ private function serveGitRequest( PhabricatorRepository $repository, PhabricatorUser $viewer) { $request = $this->getRequest(); $request_path = $this->getRequestDirectoryPath(); $repository_root = $repository->getLocalPath(); // Rebuild the query string to strip `__magic__` parameters and prevent // issues where we might interpret inputs like "service=read&service=write" // differently than the server does and pass it an unsafe command. // NOTE: This does not use getPassthroughRequestParameters() because // that code is HTTP-method agnostic and will encode POST data. $query_data = $_GET; foreach ($query_data as $key => $value) { if (!strncmp($key, '__', 2)) { unset($query_data[$key]); } } $query_string = http_build_query($query_data, '', '&'); // We're about to wipe out PATH with the rest of the environment, so // resolve the binary first. $bin = Filesystem::resolveBinary('git-http-backend'); if (!$bin) { throw new Exception("Unable to find `git-http-backend` in PATH!"); } $env = array( 'REQUEST_METHOD' => $_SERVER['REQUEST_METHOD'], 'QUERY_STRING' => $query_string, 'CONTENT_TYPE' => $request->getHTTPHeader('Content-Type'), 'HTTP_CONTENT_ENCODING' => $request->getHTTPHeader('Content-Encoding'), 'REMOTE_ADDR' => $_SERVER['REMOTE_ADDR'], 'GIT_PROJECT_ROOT' => $repository_root, 'GIT_HTTP_EXPORT_ALL' => '1', 'PATH_INFO' => $request_path, 'REMOTE_USER' => $viewer->getUsername(), // TODO: Set these correctly. // GIT_COMMITTER_NAME // GIT_COMMITTER_EMAIL ); $input = PhabricatorStartup::getRawInput(); - list($err, $stdout, $stderr) = id(new ExecFuture('%s', $bin)) + $command = csprintf('%s', $bin); + $command = PhabricatorDaemon::sudoCommandAsDaemonUser($command); + + list($err, $stdout, $stderr) = id(new ExecFuture('%C', $command)) ->setEnv($env, true) ->write($input) ->resolve(); if ($err) { if ($this->isValidGitShallowCloneResponse($stdout, $stderr)) { // Ignore the error if the response passes this special check for // validity. $err = 0; } } if ($err) { return new PhabricatorVCSResponse( 500, pht('Error %d: %s', $err, $stderr)); } return id(new DiffusionGitResponse())->setGitData($stdout); } private function getRequestDirectoryPath() { $request = $this->getRequest(); $request_path = $request->getRequestURI()->getPath(); return preg_replace('@^/diffusion/[A-Z]+@', '', $request_path); } private function authenticateHTTPRepositoryUser( $username, PhutilOpaqueEnvelope $password) { if (!PhabricatorEnv::getEnvConfig('diffusion.allow-http-auth')) { // No HTTP auth permitted. return null; } if (!strlen($username)) { // No username. return null; } if (!strlen($password->openEnvelope())) { // No password. return null; } $user = id(new PhabricatorPeopleQuery()) ->setViewer(PhabricatorUser::getOmnipotentUser()) ->withUsernames(array($username)) ->executeOne(); if (!$user) { // Username doesn't match anything. return null; } if (!$user->isUserActivated()) { // User is not activated. return null; } $password_entry = id(new PhabricatorRepositoryVCSPassword()) ->loadOneWhere('userPHID = %s', $user->getPHID()); if (!$password_entry) { // User doesn't have a password set. return null; } if (!$password_entry->comparePassword($password, $user)) { // Password doesn't match. return null; } return $user; } private function serveMercurialRequest(PhabricatorRepository $repository) { $request = $this->getRequest(); $bin = Filesystem::resolveBinary('hg'); if (!$bin) { throw new Exception("Unable to find `hg` in PATH!"); } $env = array(); $input = PhabricatorStartup::getRawInput(); $cmd = $request->getStr('cmd'); $args = $this->getMercurialArguments(); $args = $this->formatMercurialArguments($cmd, $args); if (strlen($input)) { $input = strlen($input)."\n".$input."0\n"; } - list($err, $stdout, $stderr) = id(new ExecFuture('%s serve --stdio', $bin)) + $command = csprintf('%s serve --stdio', $bin); + $command = PhabricatorDaemon::sudoCommandAsDaemonUser($command); + + list($err, $stdout, $stderr) = id(new ExecFuture('%C', $command)) ->setEnv($env, true) ->setCWD($repository->getLocalPath()) ->write("{$cmd}\n{$args}{$input}") ->resolve(); if ($err) { return new PhabricatorVCSResponse( 500, pht('Error %d: %s', $err, $stderr)); } if ($cmd == 'getbundle' || $cmd == 'changegroup' || $cmd == 'changegroupsubset') { // We're not completely sure that "changegroup" and "changegroupsubset" // actually work, they're for very old Mercurial. $body = gzcompress($stdout); } else if ($cmd == 'unbundle') { // This includes diagnostic information and anything echoed by commit // hooks. We ignore `stdout` since it just has protocol garbage, and // substitute `stderr`. $body = strlen($stderr)."\n".$stderr; } else { list($length, $body) = explode("\n", $stdout, 2); } return id(new DiffusionMercurialResponse())->setContent($body); } private function getMercurialArguments() { // Mercurial sends arguments in HTTP headers. "Why?", you might wonder, // "Why would you do this?". $args_raw = array(); for ($ii = 1; ; $ii++) { $header = 'HTTP_X_HGARG_'.$ii; if (!array_key_exists($header, $_SERVER)) { break; } $args_raw[] = $_SERVER[$header]; } $args_raw = implode('', $args_raw); return id(new PhutilQueryStringParser()) ->parseQueryString($args_raw); } private function formatMercurialArguments($command, array $arguments) { $spec = DiffusionMercurialWireProtocol::getCommandArgs($command); $out = array(); // Mercurial takes normal arguments like this: // // name // value $has_star = false; foreach ($spec as $arg_key) { if ($arg_key == '*') { $has_star = true; continue; } if (isset($arguments[$arg_key])) { $value = $arguments[$arg_key]; $size = strlen($value); $out[] = "{$arg_key} {$size}\n{$value}"; unset($arguments[$arg_key]); } } if ($has_star) { // Mercurial takes arguments for variable argument lists roughly like // this: // // * // argname1 // argvalue1 // argname2 // argvalue2 $count = count($arguments); $out[] = "* {$count}\n"; foreach ($arguments as $key => $value) { if (in_array($key, $spec)) { // We already added this argument above, so skip it. continue; } $size = strlen($value); $out[] = "{$key} {$size}\n{$value}"; } } return implode('', $out); } private function isValidGitShallowCloneResponse($stdout, $stderr) { // If you execute `git clone --depth N ...`, git sends a request which // `git-http-backend` responds to by emitting valid output and then exiting // with a failure code and an error message. If we ignore this error, // everything works. // This is a pretty funky fix: it would be nice to more precisely detect // that a request is a `--depth N` clone request, but we don't have any code // to decode protocol frames yet. Instead, look for reasonable evidence // in the error and output that we're looking at a `--depth` clone. // For evidence this isn't completely crazy, see: // https://github.com/schacon/grack/pull/7 $stdout_regexp = '(^Content-Type: application/x-git-upload-pack-result)m'; $stderr_regexp = '(The remote end hung up unexpectedly)'; $has_pack = preg_match($stdout_regexp, $stdout); $is_hangup = preg_match($stderr_regexp, $stderr); return $has_pack && $is_hangup; } + } diff --git a/src/applications/diffusion/ssh/DiffusionSSHGitReceivePackWorkflow.php b/src/applications/diffusion/ssh/DiffusionSSHGitReceivePackWorkflow.php index fb5607792c..4c379c4638 100644 --- a/src/applications/diffusion/ssh/DiffusionSSHGitReceivePackWorkflow.php +++ b/src/applications/diffusion/ssh/DiffusionSSHGitReceivePackWorkflow.php @@ -1,44 +1,45 @@ setName('git-receive-pack'); $this->setArguments( array( array( 'name' => 'dir', 'wildcard' => true, ), )); } protected function executeRepositoryOperations() { $args = $this->getArgs(); $path = head($args->getArg('dir')); $repository = $this->loadRepository($path); // This is a write, and must have write access. $this->requireWriteAccess(); - $future = new ExecFuture( - 'git-receive-pack %s', - $repository->getLocalPath()); + $command = csprintf('git-receive-pack %s', $repository->getLocalPath()); + $command = PhabricatorDaemon::sudoCommandAsDaemonUser($command); + + $future = new ExecFuture('%C', $command); $err = $this->newPassthruCommand() ->setIOChannel($this->getIOChannel()) ->setCommandChannelFromExecFuture($future) ->execute(); if (!$err) { $repository->writeStatusMessage( PhabricatorRepositoryStatusMessage::TYPE_NEEDS_UPDATE, PhabricatorRepositoryStatusMessage::CODE_OKAY); $this->waitForGitClient(); } return $err; } } diff --git a/src/applications/diffusion/ssh/DiffusionSSHGitUploadPackWorkflow.php b/src/applications/diffusion/ssh/DiffusionSSHGitUploadPackWorkflow.php index 35026b4bf8..0ccfb95508 100644 --- a/src/applications/diffusion/ssh/DiffusionSSHGitUploadPackWorkflow.php +++ b/src/applications/diffusion/ssh/DiffusionSSHGitUploadPackWorkflow.php @@ -1,36 +1,39 @@ setName('git-upload-pack'); $this->setArguments( array( array( 'name' => 'dir', 'wildcard' => true, ), )); } protected function executeRepositoryOperations() { $args = $this->getArgs(); $path = head($args->getArg('dir')); $repository = $this->loadRepository($path); - $future = new ExecFuture('git-upload-pack %s', $repository->getLocalPath()); + $command = csprintf('git-upload-pack -- %s', $repository->getLocalPath()); + $command = PhabricatorDaemon::sudoCommandAsDaemonUser($command); + + $future = new ExecFuture('%C', $command); $err = $this->newPassthruCommand() ->setIOChannel($this->getIOChannel()) ->setCommandChannelFromExecFuture($future) ->execute(); if (!$err) { $this->waitForGitClient(); } return $err; } } diff --git a/src/applications/diffusion/ssh/DiffusionSSHMercurialServeWorkflow.php b/src/applications/diffusion/ssh/DiffusionSSHMercurialServeWorkflow.php index 603fdaa7d0..46eee57610 100644 --- a/src/applications/diffusion/ssh/DiffusionSSHMercurialServeWorkflow.php +++ b/src/applications/diffusion/ssh/DiffusionSSHMercurialServeWorkflow.php @@ -1,104 +1,104 @@ setName('hg'); $this->setArguments( array( array( 'name' => 'repository', 'short' => 'R', 'param' => 'repo', ), array( 'name' => 'stdio', ), array( 'name' => 'command', 'wildcard' => true, ), )); } protected function executeRepositoryOperations() { $args = $this->getArgs(); $path = $args->getArg('repository'); $repository = $this->loadRepository($path); $args = $this->getArgs(); if (!$args->getArg('stdio')) { throw new Exception("Expected `hg ... --stdio`!"); } if ($args->getArg('command') !== array('serve')) { throw new Exception("Expected `hg ... serve`!"); } - $future = new ExecFuture( - 'hg -R %s serve --stdio', - $repository->getLocalPath()); + $command = csprintf('hg -R %s serve --stdio', $repository->getLocalPath()); + $command = PhabricatorDaemon::sudoCommandAsDaemonUser($command); - $io_channel = $this->getIOChannel(); + $future = new ExecFuture('%C', $command); + $io_channel = $this->getIOChannel(); $protocol_channel = new DiffusionSSHMercurialWireClientProtocolChannel( $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; } // If we're good, return the raw message data. return $message['raw']; } } diff --git a/src/applications/diffusion/ssh/DiffusionSSHSubversionServeWorkflow.php b/src/applications/diffusion/ssh/DiffusionSSHSubversionServeWorkflow.php index 1a0ad4e06f..23e5c6f28e 100644 --- a/src/applications/diffusion/ssh/DiffusionSSHSubversionServeWorkflow.php +++ b/src/applications/diffusion/ssh/DiffusionSSHSubversionServeWorkflow.php @@ -1,259 +1,262 @@ setName('svnserve'); $this->setArguments( array( array( 'name' => 'tunnel', 'short' => 't', ), )); } protected function executeRepositoryOperations() { $args = $this->getArgs(); if (!$args->getArg('tunnel')) { throw new Exception("Expected `svnserve -t`!"); } - $future = new ExecFuture('svnserve -t'); + $command = csprintf('svnserve -t'); + $command = PhabricatorDaemon::sudoCommandAsDaemonUser($command); + + $future = new ExecFuture('%C', $command); $this->inProtocol = new DiffusionSubversionWireProtocol(); $this->outProtocol = new DiffusionSubversionWireProtocol(); $err = id($this->newPassthruCommand()) ->setIOChannel($this->getIOChannel()) ->setCommandChannelFromExecFuture($future) ->setWillWriteCallback(array($this, 'willWriteMessageCallback')) ->setWillReadCallback(array($this, 'willReadMessageCallback')) ->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; } } $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. 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); } } $result[] = $message_raw; } if (!$result) { return null; } return implode('', $result); } private function makeInternalURI($uri_string) { $uri = new PhutilURI($uri_string); $proto = $uri->getProtocol(); if ($proto !== 'svn+ssh') { throw new Exception( pht( 'Protocol for URI "%s" MUST be "svn+ssh".', $uri_string)); } $path = $uri->getPath(); // Subversion presumably deals with this, but make sure there's nothing // skethcy going on with the URI. if (preg_match('(/\\.\\./)', $path)) { throw new Exception( pht( 'String "/../" is invalid in path specification "%s".', $uri_string)); } $repository = $this->loadRepository($path); $path = preg_replace( '(^/diffusion/[A-Z]+)', rtrim($repository->getLocalPath(), '/'), $path); if (preg_match('(^/diffusion/[A-Z]+/$)', $path)) { $path = rtrim($path, '/'); } $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(''); $this->externalBaseURI = $pre.'/diffusion/'.$repository->getCallsign(); $this->internalBaseURI = $pre.rtrim($repository->getLocalPath(), '/'); } return (string)$uri; } private function makeExternalURI($uri) { $internal = $this->internalBaseURI; $external = $this->externalBaseURI; if (strncmp($uri, $internal, strlen($internal)) === 0) { $uri = $external.substr($uri, strlen($internal)); } return $uri; } } diff --git a/src/applications/diffusion/ssh/DiffusionSSHWorkflow.php b/src/applications/diffusion/ssh/DiffusionSSHWorkflow.php index c04334441b..954e01fd1a 100644 --- a/src/applications/diffusion/ssh/DiffusionSSHWorkflow.php +++ b/src/applications/diffusion/ssh/DiffusionSSHWorkflow.php @@ -1,124 +1,123 @@ repository) { throw new Exception("Call loadRepository() before getRepository()!"); } return $this->repository; } public function getArgs() { return $this->args; } abstract protected function executeRepositoryOperations(); protected function writeError($message) { $this->getErrorChannel()->write($message); return $this; } final public function execute(PhutilArgumentParser $args) { $this->args = $args; try { return $this->executeRepositoryOperations(); } catch (Exception $ex) { $this->writeError(get_class($ex).': '.$ex->getMessage()); return 1; } } protected function loadRepository($path) { $viewer = $this->getUser(); $regex = '@^/?diffusion/(?P[A-Z]+)(?:/|$)@'; $matches = null; if (!preg_match($regex, $path, $matches)) { throw new Exception( pht( 'Unrecognized repository path "%s". Expected a path like '. '"%s".', $path, "/diffusion/X/")); } $callsign = $matches[1]; $repository = id(new PhabricatorRepositoryQuery()) ->setViewer($viewer) ->withCallsigns(array($callsign)) ->executeOne(); if (!$repository) { throw new Exception( pht('No repository "%s" exists!', $callsign)); } switch ($repository->getServeOverSSH()) { case PhabricatorRepository::SERVE_READONLY: case PhabricatorRepository::SERVE_READWRITE: // 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: throw new Exception( pht('This repository is not available over SSH.')); } $this->repository = $repository; return $repository; } protected function requireWriteAccess($protocol_command = null) { if ($this->hasWriteAccess === true) { return; } $repository = $this->getRepository(); $viewer = $this->getUser(); switch ($repository->getServeOverSSH()) { case PhabricatorRepository::SERVE_READONLY: 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.')); } 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/daemon/PhabricatorDaemon.php b/src/infrastructure/daemon/PhabricatorDaemon.php index b68518b07d..349e0f0ffe 100644 --- a/src/infrastructure/daemon/PhabricatorDaemon.php +++ b/src/infrastructure/daemon/PhabricatorDaemon.php @@ -1,22 +1,53 @@