Page MenuHomestyx hydra

No OneTemporary

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
<?php
$root = dirname(dirname(dirname(__FILE__)));
require_once $root.'/scripts/__init_script__.php';
$keys = id(new PhabricatorAuthSSHKeyQuery())
->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
<?php
$ssh_start_time = microtime(true);
$root = dirname(dirname(dirname(__FILE__)));
require_once $root.'/scripts/__init_script__.php';
$ssh_log = PhabricatorSSHLog::getLog();
-// First, figure out the authenticated user.
$args = new PhutilArgumentParser($argv);
-$args->setTagline('receive SSH requests');
+$args->setTagline('execute SSH requests');
$args->setSynopsis(<<<EOSYNOPSIS
**ssh-exec** --phabricator-ssh-user __user__ [--ssh-command __commmand__]
- Receive SSH requests.
+**ssh-exec** --phabricator-ssh-device __device__ [--ssh-command __commmand__]
+ Execute authenticated SSH requests. This script is normally invoked
+ via SSHD, but can be invoked manually for testing.
+
EOSYNOPSIS
);
$args->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 "<ip> <remote-port> <local-port>". 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 @@
<?php
final class PhabricatorAccessLogConfigOptions
extends PhabricatorApplicationConfigOptions {
public function getName() {
return pht('Access Logs');
}
public function getDescription() {
return pht('Configure the access logs, which log HTTP/SSH requests.');
}
public function getOptions() {
$common_map = array(
'C' => 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 @@
<?php
final class DiffusionGitReceivePackSSHWorkflow extends DiffusionGitSSHWorkflow {
protected function didConstruct() {
$this->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 @@
<?php
final class DiffusionGitUploadPackSSHWorkflow extends DiffusionGitSSHWorkflow {
protected function didConstruct() {
$this->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 @@
<?php
final class DiffusionMercurialServeSSHWorkflow
extends DiffusionMercurialSSHWorkflow {
protected $didSeeWrite;
protected function didConstruct() {
$this->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<error>\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 @@
<?php
abstract class DiffusionSSHWorkflow extends PhabricatorSSHWorkflow {
private $args;
private $repository;
private $hasWriteAccess;
+ private $proxyURI;
public function getRepository() {
if (!$this->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 "<ip> <remote-port> <local-port>". 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 <address> 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<callsign>[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 @@
<?php
/**
* This protocol has a good spec here:
*
* http://svn.apache.org/repos/asf/subversion/trunk/subversion/libsvn_ra_svn/protocol
*
*/
final class DiffusionSubversionServeSSHWorkflow
extends DiffusionSubversionSSHWorkflow {
private $didSeeWrite;
private $inProtocol;
private $outProtocol;
private $inSeenGreeting;
private $outPhaseCount = 0;
private $internalBaseURI;
private $externalBaseURI;
private $peekBuffer;
private $command;
private function getCommand() {
return $this->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 @@
<?php
abstract class PhabricatorSSHWorkflow extends PhabricatorManagementWorkflow {
private $user;
private $iochannel;
private $errorChannel;
+ private $isClusterRequest;
+ private $originalArguments;
public function isExecutable() {
return false;
}
public function setErrorChannel(PhutilChannel $error_channel) {
$this->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;
+ }
+
}

File Metadata

Mime Type
text/x-diff
Expires
Thu, Aug 14, 10:39 AM (5 h, 28 m)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
202257
Default Alt Text
(47 KB)

Event Timeline