diff --git a/scripts/ssh/ssh-auth.php b/scripts/ssh/ssh-auth.php index 19b1cc46b4..45e9a823db 100755 --- a/scripts/ssh/ssh-auth.php +++ b/scripts/ssh/ssh-auth.php @@ -1,156 +1,161 @@ #!/usr/bin/env php setLogName(pht('SSH Error Log')) + ->setLogPath(PhabricatorEnv::getEnvConfig('log.ssh-error.path')) + ->activateLog(); + // TODO: For now, this is using "parseParital()", not "parse()". This allows // the script to accept (and ignore) additional arguments. This preserves // backward compatibility until installs have time to migrate to the new // syntax. $args = id(new PhutilArgumentParser($argv)) ->parsePartial( array( array( 'name' => 'sshd-key', 'param' => 'k', 'help' => pht( 'Accepts the "%%k" parameter from "AuthorizedKeysCommand".'), ), )); $sshd_key = $args->getArg('sshd-key'); // NOTE: We are caching a datastructure rather than the flat key file because // the path on disk to "ssh-exec" is arbitrarily mutable at runtime. See T12397. $cache = PhabricatorCaches::getMutableCache(); $authstruct_key = PhabricatorAuthSSHKeyQuery::AUTHSTRUCT_CACHEKEY; $authstruct_raw = $cache->getKey($authstruct_key); $authstruct = null; if (strlen($authstruct_raw)) { try { $authstruct = phutil_json_decode($authstruct_raw); } catch (Exception $ex) { // Ignore any issues with the cached data; we'll just rebuild the // structure below. } } if ($authstruct === null) { $keys = id(new PhabricatorAuthSSHKeyQuery()) ->setViewer(PhabricatorUser::getOmnipotentUser()) ->withIsActive(true) ->execute(); if (!$keys) { echo pht('No keys found.')."\n"; exit(1); } $key_list = array(); foreach ($keys as $ssh_key) { $key_argv = array(); $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(); // 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; } $key_list[] = array( 'argv' => $key_argv, 'type' => $type, 'key' => $key, ); } $authstruct = array( 'keys' => $key_list, ); $authstruct_raw = phutil_json_encode($authstruct); $ttl = phutil_units('24 hours in seconds'); $cache->setKey($authstruct_key, $authstruct_raw, $ttl); } // If we've received an "--sshd-key" argument and it matches some known key, // only emit that key. (For now, if the key doesn't match, we'll fall back to // emitting all keys.) if ($sshd_key !== null) { $matches = array(); foreach ($authstruct['keys'] as $key => $key_struct) { if ($key_struct['key'] === $sshd_key) { $matches[$key] = $key_struct; } } if ($matches) { $authstruct['keys'] = $matches; } } $bin = $root.'/bin/ssh-exec'; $instance = PhabricatorEnv::getEnvConfig('cluster.instance'); $lines = array(); foreach ($authstruct['keys'] as $key_struct) { $key_argv = $key_struct['argv']; $key = $key_struct['key']; $type = $key_struct['type']; $cmd = csprintf('%s %Ls', $bin, $key_argv); if (strlen($instance)) { $cmd = csprintf('PHABRICATOR_INSTANCE=%s %C', $instance, $cmd); } // This is additional escaping for the SSH 'command="..."' string. $cmd = addcslashes($cmd, '"\\'); $options = array( 'command="'.$cmd.'"', 'no-port-forwarding', 'no-X11-forwarding', 'no-agent-forwarding', 'no-pty', ); $options = implode(',', $options); $lines[] = $options.' '.$type.' '.$key."\n"; } $authfile = implode('', $lines); echo $authfile; exit(0); diff --git a/scripts/ssh/ssh-exec.php b/scripts/ssh/ssh-exec.php index 387eb9d0d2..4a46e7562d 100755 --- a/scripts/ssh/ssh-exec.php +++ b/scripts/ssh/ssh-exec.php @@ -1,334 +1,339 @@ #!/usr/bin/env php setLogName(pht('SSH Error Log')) + ->setLogPath(PhabricatorEnv::getEnvConfig('log.ssh-error.path')) + ->activateLog(); $ssh_log = PhabricatorSSHLog::getLog(); $request_identifier = Filesystem::readRandomCharacters(12); $ssh_log->setData( array( 'Q' => $request_identifier, )); $args = new PhutilArgumentParser($argv); $args->setTagline(pht('execute SSH requests')); $args->setSynopsis(<<parseStandardArguments(); $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 (%s), which is populated by sshd.', 'SSH_ORIGINAL_COMMAND'), ), )); 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'); $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 %s and %s 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.', '--phabricator-ssh-user', '--phabricator-ssh-device', $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)); } id(new PhabricatorAuthSessionEngine()) ->willServeRequestForUser($user); } else if (strlen($device_name)) { if (!$remote_address) { throw new Exception( pht( 'Unable to identify remote address from the %s environment '. 'variable. Device authentication is accepted only from trusted '. 'sources.', 'SSH_CLIENT')); } 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_name)); } // 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 %s or %s flag.', '--phabricator-ssh-user', '--phabricator-ssh-device')); } if ($args->getArg('ssh-command')) { $original_command = $args->getArg('ssh-command'); } else { $original_command = getenv('SSH_ORIGINAL_COMMAND'); } $original_argv = id(new PhutilShellLexer()) ->splitArguments($original_command); if ($device) { // If we're authenticating as a device, the first argument may be a // "@username" argument to act as a particular user. $first_argument = head($original_argv); if (preg_match('/^@/', $first_argument)) { $act_as_name = array_shift($original_argv); $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)); } } else { $user = PhabricatorUser::getOmnipotentUser(); } } if ($user->isOmnipotent()) { $user_name = 'device/'.$device->getName(); } else { $user_name = $user->getUsername(); } $ssh_log->setData( array( 'u' => $user_name, 'P' => $user->getPHID(), )); if (!$device) { if (!$user->canEstablishSSHSessions()) { throw new Exception( pht( 'Your account ("%s") does not have permission to establish SSH '. 'sessions. Visit the web interface for more information.', $user_name)); } } $workflows = id(new PhutilClassMapQuery()) ->setAncestorClass('PhabricatorSSHWorkflow') ->setUniqueMethod('getName') ->execute(); $command_list = array_keys($workflows); $command_list = implode(', ', $command_list); $error_lines = array(); $error_lines[] = pht('Welcome to Phabricator.'); $error_lines[] = pht( 'You are logged in as %s.', $user_name); if (!$original_argv) { $error_lines[] = pht( 'You have not specified a command to run. This means you are requesting '. 'an interactive shell, but Phabricator does not provide interactive '. 'shells over SSH.'); $error_lines[] = pht( '(Usually, you should run a command like "git clone" or "hg push" '. 'instead of connecting directly with SSH.)'); $error_lines[] = pht( 'Supported commands are: %s.', $command_list); $error_lines = implode("\n\n", $error_lines); throw new PhutilArgumentUsageException($error_lines); } $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); $parseable_argv = $original_argv; array_unshift($parseable_argv, 'phabricator-ssh-exec'); $parsed_args = new PhutilArgumentParser($parseable_argv); if (empty($workflows[$command])) { $error_lines[] = pht( 'You have specified the command "%s", but that command is not '. 'supported by Phabricator. As received by Phabricator, your entire '. 'argument list was:', $command); $error_lines[] = csprintf(' $ ssh ... -- %Ls', $parseable_argv); $error_lines[] = pht( 'Supported commands are: %s.', $command_list); $error_lines = implode("\n\n", $error_lines); throw new PhutilArgumentUsageException($error_lines); } $workflow = $parsed_args->parseWorkflows($workflows); $workflow->setSSHUser($user); $workflow->setOriginalArguments($original_argv); $workflow->setIsClusterRequest($is_cluster_request); $workflow->setRequestIdentifier($request_identifier); $sock_stdin = fopen('php://stdin', 'r'); if (!$sock_stdin) { throw new Exception(pht('Unable to open stdin.')); } $sock_stdout = fopen('php://stdout', 'w'); if (!$sock_stdout) { throw new Exception(pht('Unable to open stdout.')); } $sock_stderr = fopen('php://stderr', 'w'); if (!$sock_stderr) { throw new Exception(pht('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($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' => phutil_microseconds_since($ssh_start_time), )); exit($err); diff --git a/src/applications/config/option/PhabricatorAccessLogConfigOptions.php b/src/applications/config/option/PhabricatorAccessLogConfigOptions.php index 2b9c4bbc75..45f730b977 100644 --- a/src/applications/config/option/PhabricatorAccessLogConfigOptions.php +++ b/src/applications/config/option/PhabricatorAccessLogConfigOptions.php @@ -1,140 +1,154 @@ 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.'), 'I' => pht('Cluster instance name, if configured.'), ); $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.'), // TODO: This is a reasonable thing to support in the HTTP access // log, too. 'Q' => pht('A random, unique string which identifies the request.'), ); $http_desc = pht( 'Format for the HTTP access log. Use `%s` to set the path. '. 'Available variables are:', 'log.access.path'); $http_desc .= "\n\n"; $http_desc .= $this->renderMapHelp($http_map); $ssh_desc = pht( 'Format for the SSH access log. Use %s to set the path. '. 'Available variables are:', 'log.ssh.path'); $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 ". "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), + $this->newOption('log.ssh-error.path', 'string', null) + ->setLocked(true) + ->setSummary(pht('SSH error log location.')) + ->setDescription( + pht( + 'To enable the Phabricator SSH error log, specify a path. Errors '. + 'occurring in contexts where Phabricator is serving SSH requests '. + 'will be written to this log.'. + "\n\n". + 'If not set, no log will be written.')) + ->addExample(null, pht('Disable SSH error log.')) + ->addExample( + '/var/log/phabricator/ssh-error.log', + pht('Write SSH error log here.')), ); } 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; } }