diff --git a/scripts/ssh/ssh-auth.php b/scripts/ssh/ssh-auth.php index 4e3ee7a34b..5fa5891f49 100755 --- a/scripts/ssh/ssh-auth.php +++ b/scripts/ssh/ssh-auth.php @@ -1,70 +1,76 @@ #!/usr/bin/env php setViewer(PhabricatorUser::getOmnipotentUser()) ->execute(); -foreach ($keys as $key => $ssh_key) { - // For now, filter out any keys which don't belong to users. Eventually we - // may allow devices to use this channel. - if (!($ssh_key->getObject() instanceof PhabricatorUser)) { - unset($keys[$key]); - continue; - } -} - if (!$keys) { echo pht('No keys found.')."\n"; exit(1); } $bin = $root.'/bin/ssh-exec'; foreach ($keys as $ssh_key) { - $user = $ssh_key->getObject()->getUsername(); - $key_argv = array(); - $key_argv[] = '--phabricator-ssh-user'; - $key_argv[] = $user; + $object = $ssh_key->getObject(); + if ($object instanceof PhabricatorUser) { + $key_argv[] = '--phabricator-ssh-user'; + $key_argv[] = $object->getUsername(); + } else if ($object instanceof AlmanacDevice) { + if (!$ssh_key->getIsTrusted()) { + // If this key is not a trusted device key, don't allow SSH + // authentication. + continue; + } + $key_argv[] = '--phabricator-ssh-device'; + $key_argv[] = $object->getName(); + } else { + // We don't know what sort of key this is; don't permit SSH auth. + continue; + } + + $key_argv[] = '--phabricator-ssh-key'; + $key_argv[] = $ssh_key->getID(); $cmd = csprintf('%s %Ls', $bin, $key_argv); $instance = PhabricatorEnv::getEnvConfig('cluster.instance'); if (strlen($instance)) { $cmd = csprintf('PHABRICATOR_INSTANCE=%s %C', $instance, $cmd); } // This is additional escaping for the SSH 'command="..."' string. $cmd = addcslashes($cmd, '"\\'); // Strip out newlines and other nonsense from the key type and key body. $type = $ssh_key->getKeyType(); $type = preg_replace('@[\x00-\x20]+@', '', $type); if (!strlen($type)) { continue; } $key = $ssh_key->getKeyBody(); $key = preg_replace('@[\x00-\x20]+@', '', $key); if (!strlen($key)) { continue; } $options = array( 'command="'.$cmd.'"', 'no-port-forwarding', 'no-X11-forwarding', 'no-agent-forwarding', 'no-pty', ); $options = implode(',', $options); $lines[] = $options.' '.$type.' '.$key."\n"; } echo implode('', $lines); exit(0); diff --git a/scripts/ssh/ssh-exec.php b/scripts/ssh/ssh-exec.php index 2e57efb6b8..39bed2a701 100755 --- a/scripts/ssh/ssh-exec.php +++ b/scripts/ssh/ssh-exec.php @@ -1,162 +1,289 @@ #!/usr/bin/env php setTagline('receive SSH requests'); +$args->setTagline('execute SSH requests'); $args->setSynopsis(<<parse( array( array( 'name' => 'phabricator-ssh-user', 'param' => 'username', + 'help' => pht( + 'If the request authenticated with a user key, the name of the '. + 'user.'), + ), + array( + 'name' => 'phabricator-ssh-device', + 'param' => 'name', + 'help' => pht( + 'If the request authenticated with a device key, the name of the '. + 'device.'), + ), + array( + 'name' => 'phabricator-ssh-key', + 'param' => 'id', + 'help' => pht( + 'The ID of the SSH key which authenticated this request. This is '. + 'used to allow logs to report when specific keys were used, to make '. + 'it easier to manage credentials.'), ), array( 'name' => 'ssh-command', 'param' => 'command', + 'help' => pht( + 'Provide a command to execute. This makes testing this script '. + 'easier. When running normally, the command is read from the '. + 'environment (SSH_ORIGINAL_COMMAND), which is populated by sshd.'), ), )); try { + $remote_address = null; + $ssh_client = getenv('SSH_CLIENT'); + if ($ssh_client) { + // This has the format " ". Grab the IP. + $remote_address = head(explode(' ', $ssh_client)); + $ssh_log->setData( + array( + 'r' => $remote_address, + )); + } + + $key_id = $args->getArg('phabricator-ssh-key'); + if ($key_id) { + $ssh_log->setData( + array( + 'k' => $key_id, + )); + } + $user_name = $args->getArg('phabricator-ssh-user'); - if (!strlen($user_name)) { - throw new Exception('No username.'); + $device_name = $args->getArg('phabricator-ssh-device'); + + $user = null; + $device = null; + $is_cluster_request = false; + + if ($user_name && $device_name) { + throw new Exception( + pht( + 'The --phabricator-ssh-user and --phabricator-ssh-device flags are '. + 'mutually exclusive. You can not authenticate as both a user ("%s") '. + 'and a device ("%s"). Specify one or the other, but not both.', + $user_name, + $device_name)); + } else if (strlen($user_name)) { + $user = id(new PhabricatorPeopleQuery()) + ->setViewer(PhabricatorUser::getOmnipotentUser()) + ->withUsernames(array($user_name)) + ->executeOne(); + if (!$user) { + throw new Exception( + pht( + 'Invalid username ("%s"). There is no user with this username.', + $user_name)); + } + } else if (strlen($device_name)) { + if (!$remote_address) { + throw new Exception( + pht( + 'Unable to identify remote address from the SSH_CLIENT environment '. + 'variable. Device authentication is accepted only from trusted '. + 'sources.')); + } + + if (!PhabricatorEnv::isClusterAddress($remote_address)) { + throw new Exception( + pht( + 'This request originates from outside of the Phabricator cluster '. + 'address range. Requests signed with a trusted device key must '. + 'originate from trusted hosts.')); + } + + $device = id(new AlmanacDeviceQuery()) + ->setViewer(PhabricatorUser::getOmnipotentUser()) + ->withNames(array($device_name)) + ->executeOne(); + if (!$device) { + throw new Exception( + pht( + 'Invalid device name ("%s"). There is no device with this name.', + $device->getName())); + } + + // We're authenticated as a device, but we're going to read the user out of + // the command below. + $is_cluster_request = true; + } else { + throw new Exception( + pht( + 'This script must be invoked with either the --phabricator-ssh-user '. + 'or --phabricator-ssh-device flag.')); + } + + if ($args->getArg('ssh-command')) { + $original_command = $args->getArg('ssh-command'); + } else { + $original_command = getenv('SSH_ORIGINAL_COMMAND'); } - $user = id(new PhabricatorUser())->loadOneWhere( - 'userName = %s', - $user_name); - if (!$user) { - throw new Exception('Invalid username.'); + $original_argv = id(new PhutilShellLexer()) + ->splitArguments($original_command); + + if ($device) { + $act_as_name = array_shift($original_argv); + if (!preg_match('/^@/', $act_as_name)) { + throw new Exception( + pht( + 'Commands executed by devices must identify an acting user in the '. + 'first command argument. This request was not constructed '. + 'properly.')); + } + + $act_as_name = substr($act_as_name, 1); + $user = id(new PhabricatorPeopleQuery()) + ->setViewer(PhabricatorUser::getOmnipotentUser()) + ->withUsernames(array($act_as_name)) + ->executeOne(); + if (!$user) { + throw new Exception( + pht( + 'Device request identifies an acting user with an invalid '. + 'username ("%s"). There is no user with this username.', + $act_as_name)); + } } $ssh_log->setData( array( 'u' => $user->getUsername(), 'P' => $user->getPHID(), )); if (!$user->isUserActivated()) { - throw new Exception(pht('Your account is not activated.')); - } - - if ($args->getArg('ssh-command')) { - $original_command = $args->getArg('ssh-command'); - } else { - $original_command = getenv('SSH_ORIGINAL_COMMAND'); + throw new Exception( + pht( + 'Your account ("%s") is not activated. Visit the web interface '. + 'for more information.', + $user->getUsername())); } $workflows = id(new PhutilSymbolLoader()) ->setAncestorClass('PhabricatorSSHWorkflow') ->loadObjects(); $workflow_names = mpull($workflows, 'getName', 'getName'); - // Now, rebuild the original command. - $original_argv = id(new PhutilShellLexer()) - ->splitArguments($original_command); if (!$original_argv) { throw new Exception( pht( "Welcome to Phabricator.\n\n". "You are logged in as %s.\n\n". "You haven't specified a command to run. This means you're requesting ". "an interactive shell, but Phabricator does not provide an ". "interactive shell over SSH.\n\n". "Usually, you should run a command like `git clone` or `hg push` ". "rather than connecting directly with SSH.\n\n". "Supported commands are: %s.", $user->getUsername(), implode(', ', $workflow_names))); } - $log_argv = implode(' ', array_slice($original_argv, 1)); + $log_argv = implode(' ', $original_argv); $log_argv = id(new PhutilUTF8StringTruncator()) ->setMaximumCodepoints(128) ->truncateString($log_argv); $ssh_log->setData( array( 'C' => $original_argv[0], 'U' => $log_argv, )); $command = head($original_argv); - array_unshift($original_argv, 'phabricator-ssh-exec'); - $original_args = new PhutilArgumentParser($original_argv); + $parseable_argv = $original_argv; + array_unshift($parseable_argv, 'phabricator-ssh-exec'); + + $parsed_args = new PhutilArgumentParser($parseable_argv); if (empty($workflow_names[$command])) { throw new Exception('Invalid command.'); } - $workflow = $original_args->parseWorkflows($workflows); + $workflow = $parsed_args->parseWorkflows($workflows); $workflow->setUser($user); + $workflow->setOriginalArguments($original_argv); + $workflow->setIsClusterRequest($is_cluster_request); $sock_stdin = fopen('php://stdin', 'r'); if (!$sock_stdin) { throw new Exception('Unable to open stdin.'); } $sock_stdout = fopen('php://stdout', 'w'); if (!$sock_stdout) { throw new Exception('Unable to open stdout.'); } $sock_stderr = fopen('php://stderr', 'w'); if (!$sock_stderr) { throw new Exception('Unable to open stderr.'); } $socket_channel = new PhutilSocketChannel( $sock_stdin, $sock_stdout); $error_channel = new PhutilSocketChannel(null, $sock_stderr); $metrics_channel = new PhutilMetricsChannel($socket_channel); $workflow->setIOChannel($metrics_channel); $workflow->setErrorChannel($error_channel); $rethrow = null; try { - $err = $workflow->execute($original_args); + $err = $workflow->execute($parsed_args); $metrics_channel->flush(); $error_channel->flush(); } catch (Exception $ex) { $rethrow = $ex; } // Always write this if we got as far as building a metrics channel. $ssh_log->setData( array( 'i' => $metrics_channel->getBytesRead(), 'o' => $metrics_channel->getBytesWritten(), )); if ($rethrow) { throw $rethrow; } } catch (Exception $ex) { fwrite(STDERR, "phabricator-ssh-exec: ".$ex->getMessage()."\n"); $err = 1; } $ssh_log->setData( array( 'c' => $err, 'T' => (int)(1000000 * (microtime(true) - $ssh_start_time)), )); exit($err); diff --git a/src/applications/config/option/PhabricatorAccessLogConfigOptions.php b/src/applications/config/option/PhabricatorAccessLogConfigOptions.php index c413088fa6..6a6f1d46c2 100644 --- a/src/applications/config/option/PhabricatorAccessLogConfigOptions.php +++ b/src/applications/config/option/PhabricatorAccessLogConfigOptions.php @@ -1,125 +1,126 @@ pht('The controller or workflow which handled the request.'), 'c' => pht('The HTTP response code or process exit code.'), 'D' => pht('The request date.'), 'e' => pht('Epoch timestamp.'), 'h' => pht("The webserver's host name."), 'p' => pht('The PID of the server process.'), 'r' => pht('The remote IP.'), 'T' => pht('The request duration, in microseconds.'), 'U' => pht('The request path, or request target.'), 'm' => pht('For conduit, the Conduit method which was invoked.'), 'u' => pht('The logged-in username, if one is logged in.'), 'P' => pht('The logged-in user PHID, if one is logged in.'), 'i' => pht('Request input, in bytes.'), 'o' => pht('Request output, in bytes.'), ); $http_map = $common_map + array( 'R' => pht('The HTTP referrer.'), 'M' => pht('The HTTP method.'), ); $ssh_map = $common_map + array( 's' => pht('The system user.'), 'S' => pht('The system sudo user.'), + 'k' => pht('ID of the SSH key used to authenticate the request.'), ); $http_desc = pht( 'Format for the HTTP access log. Use {{log.access.path}} to set the '. 'path. Available variables are:'); $http_desc .= "\n\n"; $http_desc .= $this->renderMapHelp($http_map); $ssh_desc = pht( 'Format for the SSH access log. Use {{log.ssh.path}} to set the '. 'path. Available variables are:'); $ssh_desc .= "\n\n"; $ssh_desc .= $this->renderMapHelp($ssh_map); return array( $this->newOption('log.access.path', 'string', null) ->setLocked(true) ->setSummary(pht('Access log location.')) ->setDescription( pht( "To enable the Phabricator access log, specify a path. The ". "access log can provide more detailed information about ". "Phabricator access than normal HTTP access logs (for instance, ". "it can show logged-in users, controllers, and other application ". "data).\n\n". "If not set, no log will be written.")) ->addExample( null, pht('Disable access log.')) ->addExample( '/var/log/phabricator/access.log', pht('Write access log here.')), $this->newOption( 'log.access.format', // NOTE: This is 'wild' intead of 'string' so "\t" and such can be // specified. 'wild', "[%D]\t%p\t%h\t%r\t%u\t%C\t%m\t%U\t%R\t%c\t%T") ->setLocked(true) ->setSummary(pht('Access log format.')) ->setDescription($http_desc), $this->newOption('log.ssh.path', 'string', null) ->setLocked(true) ->setSummary(pht('SSH log location.')) ->setDescription( pht( "To enable the Phabricator SSH log, specify a path. The ". "access log can provide more detailed information about SSH ". "access than a normal SSH log (for instance, it can show ". "logged-in users, commands, and other application data).\n\n". "If not set, no log will be written.")) ->addExample( null, pht('Disable SSH log.')) ->addExample( '/var/log/phabricator/ssh.log', pht('Write SSH log here.')), $this->newOption( 'log.ssh.format', 'wild', "[%D]\t%p\t%h\t%r\t%s\t%S\t%u\t%C\t%U\t%c\t%T\t%i\t%o") ->setLocked(true) ->setSummary(pht('SSH log format.')) ->setDescription($ssh_desc), ); } private function renderMapHelp(array $map) { $desc = ''; foreach ($map as $key => $kdesc) { $desc .= " - `%".$key."` ".$kdesc."\n"; } $desc .= "\n"; $desc .= pht( "If a variable isn't available (for example, %%m appears in the file ". "format but the request is not a Conduit request), it will be rendered ". "as '-'"); $desc .= "\n\n"; $desc .= pht( "Note that the default format is subject to change in the future, so ". "if you rely on the log's format, specify it explicitly."); return $desc; } } diff --git a/src/applications/diffusion/ssh/DiffusionGitReceivePackSSHWorkflow.php b/src/applications/diffusion/ssh/DiffusionGitReceivePackSSHWorkflow.php index 1a52fab6b3..e4eabc72ef 100644 --- a/src/applications/diffusion/ssh/DiffusionGitReceivePackSSHWorkflow.php +++ b/src/applications/diffusion/ssh/DiffusionGitReceivePackSSHWorkflow.php @@ -1,43 +1,47 @@ setName('git-receive-pack'); $this->setArguments( array( array( 'name' => 'dir', 'wildcard' => true, ), )); } protected function executeRepositoryOperations() { $repository = $this->getRepository(); // This is a write, and must have write access. $this->requireWriteAccess(); - $command = csprintf('git-receive-pack %s', $repository->getLocalPath()); + if ($this->shouldProxy()) { + $command = $this->getProxyCommand(); + } else { + $command = csprintf('git-receive-pack %s', $repository->getLocalPath()); + } $command = PhabricatorDaemon::sudoCommandAsDaemonUser($command); $future = id(new ExecFuture('%C', $command)) ->setEnv($this->getEnvironment()); $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/DiffusionGitUploadPackSSHWorkflow.php b/src/applications/diffusion/ssh/DiffusionGitUploadPackSSHWorkflow.php index beea59edb0..4812b960a0 100644 --- a/src/applications/diffusion/ssh/DiffusionGitUploadPackSSHWorkflow.php +++ b/src/applications/diffusion/ssh/DiffusionGitUploadPackSSHWorkflow.php @@ -1,37 +1,41 @@ setName('git-upload-pack'); $this->setArguments( array( array( 'name' => 'dir', 'wildcard' => true, ), )); } protected function executeRepositoryOperations() { $repository = $this->getRepository(); - $command = csprintf('git-upload-pack -- %s', $repository->getLocalPath()); + if ($this->shouldProxy()) { + $command = $this->getProxyCommand(); + } else { + $command = csprintf('git-upload-pack -- %s', $repository->getLocalPath()); + } $command = PhabricatorDaemon::sudoCommandAsDaemonUser($command); $future = id(new ExecFuture('%C', $command)) ->setEnv($this->getEnvironment()); $err = $this->newPassthruCommand() ->setIOChannel($this->getIOChannel()) ->setCommandChannelFromExecFuture($future) ->execute(); if (!$err) { $this->waitForGitClient(); } return $err; } } diff --git a/src/applications/diffusion/ssh/DiffusionMercurialServeSSHWorkflow.php b/src/applications/diffusion/ssh/DiffusionMercurialServeSSHWorkflow.php index 6afa70bf9c..b6c22b3a7e 100644 --- a/src/applications/diffusion/ssh/DiffusionMercurialServeSSHWorkflow.php +++ b/src/applications/diffusion/ssh/DiffusionMercurialServeSSHWorkflow.php @@ -1,108 +1,114 @@ 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); } protected function executeRepositoryOperations() { $repository = $this->getRepository(); $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`!'); } - $command = csprintf('hg -R %s serve --stdio', $repository->getLocalPath()); + 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; } // If we're good, return the raw message data. return $message['raw']; } } diff --git a/src/applications/diffusion/ssh/DiffusionSSHWorkflow.php b/src/applications/diffusion/ssh/DiffusionSSHWorkflow.php index 4b0ec5d190..c8345a38d1 100644 --- a/src/applications/diffusion/ssh/DiffusionSSHWorkflow.php +++ b/src/applications/diffusion/ssh/DiffusionSSHWorkflow.php @@ -1,152 +1,221 @@ 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', ); $ssh_client = getenv('SSH_CLIENT'); if ($ssh_client) { // This has the format " ". Grab the IP. $remote_address = head(explode(' ', $ssh_client)); $env[DiffusionCommitHookEngine::ENV_REMOTE_ADDRESS] = $remote_address; } return $env; } /** * Identify and load the affected repository. */ abstract protected function identifyRepository(); abstract protected function executeRepositoryOperations(); protected function writeError($message) { $this->getErrorChannel()->write($message); return $this; } + protected function shouldProxy() { + return (bool)$this->proxyURI; + } + + protected function getProxyCommand() { + $uri = new PhutilURI($this->proxyURI); + + $username = PhabricatorEnv::getEnvConfig('cluster.instance'); + if (!strlen($username)) { + $username = PhabricatorEnv::getEnvConfig('diffusion.ssh-user'); + if (!strlen($username)) { + 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; $repository = $this->identifyRepository(); $this->setRepository($repository); - // TODO: Here, we would make a proxying decision, had I implemented - // proxying yet. + $is_cluster_request = $this->getIsClusterRequest(); + $uri = $repository->getAlmanacServiceURI( + $this->getUser(), + $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) { $viewer = $this->getUser(); $regex = '@^/?diffusion/(?P[A-Z]+)(?:/|\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.')); } 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, DiffusionPushCapability::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/applications/diffusion/ssh/DiffusionSubversionServeSSHWorkflow.php b/src/applications/diffusion/ssh/DiffusionSubversionServeSSHWorkflow.php index b7f16f6561..9a26494869 100644 --- a/src/applications/diffusion/ssh/DiffusionSubversionServeSSHWorkflow.php +++ b/src/applications/diffusion/ssh/DiffusionSubversionServeSSHWorkflow.php @@ -1,424 +1,428 @@ 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( 'svnserve subprocess exited before emitting a protocol frame.')); } } $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); } } 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('Expected `svnserve -t`!'); } - $command = csprintf( - 'svnserve -t --tunnel-user=%s', - $this->getUser()->getUsername()); - $command = PhabricatorDaemon::sudoCommandAsDaemonUser($command); + if ($this->shouldProxy()) { + $command = $this->getProxyCommand(); + } else { + $command = csprintf( + 'svnserve -t --tunnel-user=%s', + $this->getUser()->getUsername()); + } + $command = PhabricatorDaemon::sudoCommandAsDaemonUser($command); $future = new ExecFuture('%C', $command); $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': // ( add-file ( path dir-token file-token [ 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 "svn+ssh".', $uri_string)); } $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 "/../" is invalid in path specification "%s".', $uri_string)); } $path = $this->normalizeSVNPath($path); return $path; } private function makeInternalURI($uri_string) { $uri = new PhutilURI($uri_string); $repository = $this->getRepository(); $path = $this->getPathFromSubversionURI($uri_string); $path = preg_replace( '(^/diffusion/[A-Z]+)', rtrim($repository->getLocalPath(), '/'), $path); if (preg_match('(^/diffusion/[A-Z]+/\z)', $path)) { $path = rtrim($path, '/'); } // 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 = '/diffusion/'.$repository->getCallsign(); $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) { $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; } } diff --git a/src/infrastructure/ssh/PhabricatorSSHWorkflow.php b/src/infrastructure/ssh/PhabricatorSSHWorkflow.php index 8bcd289054..b5ac17b7bf 100644 --- a/src/infrastructure/ssh/PhabricatorSSHWorkflow.php +++ b/src/infrastructure/ssh/PhabricatorSSHWorkflow.php @@ -1,66 +1,86 @@ errorChannel = $error_channel; return $this; } public function getErrorChannel() { return $this->errorChannel; } public function setUser(PhabricatorUser $user) { $this->user = $user; return $this; } public function getUser() { return $this->user; } public function setIOChannel(PhutilChannel $channel) { $this->iochannel = $channel; return $this; } public function getIOChannel() { return $this->iochannel; } public function readAllInput() { $channel = $this->getIOChannel(); while ($channel->update()) { PhutilChannel::waitForAny(array($channel)); if (!$channel->isOpenForReading()) { break; } } return $channel->read(); } public function writeIO($data) { $this->getIOChannel()->write($data); return $this; } public function writeErrorIO($data) { $this->getErrorChannel()->write($data); return $this; } protected function newPassthruCommand() { return id(new PhabricatorSSHPassthruCommand()) ->setErrorChannel($this->getErrorChannel()); } + public function setIsClusterRequest($is_cluster_request) { + $this->isClusterRequest = $is_cluster_request; + return $this; + } + + public function getIsClusterRequest() { + return $this->isClusterRequest; + } + + public function setOriginalArguments(array $original_arguments) { + $this->originalArguments = $original_arguments; + return $this; + } + + public function getOriginalArguments() { + return $this->originalArguments; + } + }