diff --git a/resources/windows/zeroconf.ps1 b/resources/windows/sshzeroconf.ps1 rename from resources/windows/zeroconf.ps1 rename to resources/windows/sshzeroconf.ps1 diff --git a/resources/windows/winrmzeroconf.ps1 b/resources/windows/winrmzeroconf.ps1 new file mode 100644 --- /dev/null +++ b/resources/windows/winrmzeroconf.ps1 @@ -0,0 +1,32 @@ +# Allow running scripts on this system +Set-ExecutionPolicy -Force Bypass + +# Enable plain HTTP WinRM remoting +Set-WSManInstance WinRM/Config/Service/Auth -ValueSet @{Basic = $true} +Set-WSManInstance WinRM/Config/Service -ValueSet @{AllowUnencrypted = $true} +Set-WSManInstance WinRM/Config/Client -ValueSet @{TrustedHosts="*"} + +# Prevent Powershell from using stupid defaults. +Set-Item -ErrorAction SilentlyContinue ` + WSMAN:localhost\Shell\MaxMemoryPerShellMB 100000000 +Set-Item -ErrorAction SilentlyContinue ` + WSMAN:localhost\Shell\MaxShellsPerUser 10000 + +Push-Location WSMAN:localhost\Plugin\ +foreach ($dir in Get-ChildItem) { + $name = $dir.Name + try { + Set-Item -ErrorAction SilentlyContinue ` + -Path "$name\Quotas\MaxConcurrentCommandsPerShell" 100000000 + } catch [System.InvalidOperationException] { + } + try { + Set-Item -ErrorAction SilentlyContinue ` + -Path "$name\Quotas\MaxMemoryPerShellMB" 10000 + } catch [System.InvalidOperationException] { + } +} +Pop-Location + +# Reload remoting configuration. +Restart-Service winrm diff --git a/src/__phutil_library_map__.php b/src/__phutil_library_map__.php --- a/src/__phutil_library_map__.php +++ b/src/__phutil_library_map__.php @@ -714,7 +714,9 @@ 'DrydockResourceViewController' => 'applications/drydock/controller/DrydockResourceViewController.php', 'DrydockSFTPFilesystemInterface' => 'applications/drydock/interface/filesystem/DrydockSFTPFilesystemInterface.php', 'DrydockSSHCommandInterface' => 'applications/drydock/interface/command/DrydockSSHCommandInterface.php', + 'DrydockSetupCheckWinRM' => 'applications/drydock/check/DrydockSetupCheckWinRM.php', 'DrydockWebrootInterface' => 'applications/drydock/interface/webroot/DrydockWebrootInterface.php', + 'DrydockWinRMCommandInterface' => 'applications/drydock/interface/command/DrydockWinRMCommandInterface.php', 'DrydockWorkingCopyBlueprintImplementation' => 'applications/drydock/blueprint/DrydockWorkingCopyBlueprintImplementation.php', 'FeedConduitAPIMethod' => 'applications/feed/conduit/FeedConduitAPIMethod.php', 'FeedPublishConduitAPIMethod' => 'applications/feed/conduit/FeedPublishConduitAPIMethod.php', @@ -3716,7 +3718,9 @@ 'DrydockResourceViewController' => 'DrydockResourceController', 'DrydockSFTPFilesystemInterface' => 'DrydockFilesystemInterface', 'DrydockSSHCommandInterface' => 'DrydockCommandInterface', + 'DrydockSetupCheckWinRM' => 'PhabricatorSetupCheck', 'DrydockWebrootInterface' => 'DrydockInterface', + 'DrydockWinRMCommandInterface' => 'DrydockCommandInterface', 'DrydockWorkingCopyBlueprintImplementation' => 'DrydockBlueprintImplementation', 'FeedConduitAPIMethod' => 'ConduitAPIMethod', 'FeedPublishConduitAPIMethod' => 'FeedConduitAPIMethod', diff --git a/src/applications/drydock/blueprint/DrydockAmazonEC2HostBlueprintImplementation.php b/src/applications/drydock/blueprint/DrydockAmazonEC2HostBlueprintImplementation.php --- a/src/applications/drydock/blueprint/DrydockAmazonEC2HostBlueprintImplementation.php +++ b/src/applications/drydock/blueprint/DrydockAmazonEC2HostBlueprintImplementation.php @@ -95,10 +95,33 @@ ->withPHIDs(array($this->getDetail('keypair'))) ->executeOne(); + if ($credential === null) { + throw new Exception('Specified credential does not exist!'); + } + $this->log(pht( 'Using credential %d to allocate.', $credential->getID())); + $winrm_auth_id = null; + if ($this->getDetail('protocol') === 'winrm') { + $winrm_auth = id(new PassphraseCredentialQuery()) + ->setViewer(PhabricatorUser::getOmnipotentUser()) + ->withPHIDs(array($this->getDetail('winrm-auth'))) + ->executeOne(); + + if ($winrm_auth === null) { + throw new Exception( + 'Specified credential for WinRM auth does not exist!'); + } + + $winrm_auth_id = $winrm_auth->getID(); + + $this->log(pht( + 'Using credential %d to authenticate over WinRM.', + $winrm_auth->getID())); + } + try { $existing_keys = $this->getAWSEC2Future() ->setRawAWSQuery( @@ -151,11 +174,11 @@ $i++; } - if (!$this->getDetail('skip-ssh-setup-windows')) { + if (!$this->getDetail('skip-setup-windows')) { if ($this->getDetail('platform') === 'windows') { $this->log(pht('Enabled SSH automatic configuration for Windows.')); $settings['UserData'] = id(new WindowsZeroConf()) - ->getEncodedUserData($credential); + ->getEncodedUserData($credential, $this->getDetail('protocol')); } } @@ -428,8 +451,10 @@ ->setAttributes(array( 'instance-id' => $instance_id, 'platform' => $this->getDetail('platform'), + 'protocol' => $this->getDetail('protocol'), 'path' => $this->getDetail('storage-path'), 'credential' => $credential->getID(), + 'winrm-auth' => $winrm_auth_id, 'aws-status' => 'Instance Requested')) ->save(); @@ -638,37 +663,46 @@ // Update address and port attributes. $resource->setAttribute('host', $address); - $resource->setAttribute('port', 22); + if ($resource->getAttribute('protocol') === 'winrm') { + $resource->setAttribute('port', 5985); + } else { + $resource->setAttribute('port', 22); + } $resource->save(); + $protocol_name = ''; + if ($resource->getAttribute('protocol') === 'winrm') { + $protocol_name = 'WinRM'; + } else { + $protocol_name = 'SSH'; + } + $this->log(pht( - 'Waiting for a successful SSH connection')); - - // Wait until we get a successful SSH connection. - $ssh = id(new DrydockSSHCommandInterface()) - ->setConfiguration(array( - 'host' => $resource->getAttribute('host'), - 'port' => $resource->getAttribute('port'), - 'credential' => $resource->getAttribute('credential'), - 'platform' => $resource->getAttribute('platform'))); - $ssh->setConnectTimeout(60); + 'Waiting for a successful %s connection', $protocol_name)); + + // Wait until we get a successful connection. + $ssh = $this->getInterface($resource, $lease, 'command'); $ssh->setExecTimeout(60); + if ($resource->getAttribute('protocol') === 'ssh') { + $ssh->setConnectTimeout(60); + } $resource->setAttribute( 'aws-status', - 'Waiting for successful SSH connection'); + 'Waiting for successful %s connection', $protocol_name); $resource->save(); while (true) { try { $this->log(pht( - 'Attempting to connect to \'%s\' via SSH', - $instance_id)); + 'Attempting to connect to \'%s\' via %s', + $instance_id, + $protocol_name)); $ssh_future = $ssh->getExecFuture('echo "test"'); $ssh_future->resolvex(); if ($ssh_future->getWasKilledByTimeout()) { - throw new Exception('SSH execution timed out.'); + throw new Exception('%s execution timed out.', $protocol_name); } break; @@ -688,7 +722,8 @@ $instance_state = (string)$instance->instanceState->name; $this->log(pht( - 'SSH connection not yet ready; instance is in state \'%s\'', + '%s connection not yet ready; instance is in state \'%s\'', + $protocol_name, $instance_state)); if ($instance_state === 'shutting-down' || @@ -696,8 +731,9 @@ $this->log(pht( 'Instance has ended up in state \'%s\' while waiting for an '. - 'SSH connection', - $instance_state)); + '%s connection', + $instance_state, + $protocol_name)); // Deallocate and release the public IP address if we allocated one. if ($resource->getAttribute('eip-allocated')) { @@ -950,13 +986,25 @@ switch ($type) { case 'command': - return id(new DrydockSSHCommandInterface()) - ->setConfiguration(array( - 'host' => $resource->getAttribute('host'), - 'port' => $resource->getAttribute('port'), - 'credential' => $resource->getAttribute('credential'), - 'platform' => $resource->getAttribute('platform'))) - ->setWorkingDirectory($lease->getAttribute('path')); + if ($resource->getAttribute('protocol') === 'ssh') { + return id(new DrydockSSHCommandInterface()) + ->setConfiguration(array( + 'host' => $resource->getAttribute('host'), + 'port' => $resource->getAttribute('port'), + 'credential' => $resource->getAttribute('credential'), + 'platform' => $resource->getAttribute('platform'),)) + ->setWorkingDirectory($lease->getAttribute('path')); + } else if ($resource->getAttribute('protocol') === 'winrm') { + return id(new DrydockWinRMCommandInterface()) + ->setConfiguration(array( + 'host' => $resource->getAttribute('host'), + 'port' => $resource->getAttribute('port'), + 'credential' => $resource->getAttribute('winrm-auth'), + 'platform' => $resource->getAttribute('platform'),)) + ->setWorkingDirectory($lease->getAttribute('path')); + } else { + throw new Exception('Unsupported protocol for remoting'); + } case 'filesystem': return id(new DrydockSFTPFilesystemInterface()) ->setConfiguration(array( @@ -1007,6 +1055,22 @@ 'required' => true, 'caption' => pht('e.g. %s or %s', 'windows', 'linux') ), + 'protocol' => array( + 'name' => pht('Protocol'), + 'type' => 'select', + 'required' => true, + 'options' => array( + 'ssh' => 'SSH', + 'winrm' => 'WinRM (Windows platform only)',), + ), + 'winrm-auth' => array( + 'name' => pht('WinRM Credentials'), + 'type' => 'credential', + 'credential.provides' + => PassphraseCredentialTypePassword::PROVIDES_TYPE, + 'caption' => pht( + 'This is only required if the protocol is set to WinRM.'), + ), 'subnet-id' => array( 'name' => pht('VPC Subnet'), 'type' => 'text', @@ -1049,13 +1113,14 @@ 'connect to the machine on it\'s private IP address (because of '. 'firewall rules).'), ), - 'skip-ssh-setup-windows' => array( - 'name' => pht('Skip SSH setup on Windows'), + 'skip-setup-windows' => array( + 'name' => pht('Skip setup on Windows'), 'type' => 'bool', 'caption' => pht( - 'If SSH is already configured on a Windows AMI, check this option. '. - 'By default, Phabricator will automatically install and configure '. - 'SSH on the Windows image.') + 'If SSH or WinRM is already configured on a Windows AMI, check '. + 'this option. By default, Phabricator will automatically install '. + 'and configure SSH or WinRM on the Windows image (depending on the '. + 'protocol chosen).'), ), 'spot' => array( 'name' => pht('Spot Instances'), diff --git a/src/applications/drydock/blueprint/DrydockPreallocatedHostBlueprintImplementation.php b/src/applications/drydock/blueprint/DrydockPreallocatedHostBlueprintImplementation.php --- a/src/applications/drydock/blueprint/DrydockPreallocatedHostBlueprintImplementation.php +++ b/src/applications/drydock/blueprint/DrydockPreallocatedHostBlueprintImplementation.php @@ -52,17 +52,34 @@ // we have all the information we need. PhutilTypeSpec::checkMap( $resource->getAttributesForTypeSpec( - array('platform', 'host', 'port', 'credential', 'path')), + array('platform', 'protocol', 'host', 'port', 'credential', 'path')), array( 'platform' => 'string', + 'protocol' => 'string', 'host' => 'string', 'port' => 'string', // Value is a string from the command line 'credential' => 'string', 'path' => 'string', )); $v_platform = $resource->getAttribute('platform'); + $v_protocol = $resource->getAttribute('protocol'); $v_path = $resource->getAttribute('path'); + // Verify the provided protocol. + if ($v_platform === 'windows') { + if ($v_protocol !== 'ssh' && $v_protocol !== 'winrm') { + throw new Exception( + 'Invalid protocol set for Windows platform; '. + 'expected \'ssh\' or \'winrm\'.'); + } + } else { + if ($v_protocol !== 'ssh') { + throw new Exception( + 'Invalid protocol set for UNIX platform; '. + 'expected \'ssh\'.'); + } + } + // Similar to DrydockLocalHostBlueprint, we create a folder // on the remote host that the lease can use. @@ -99,7 +116,12 @@ switch ($type) { case 'command': - return id(new DrydockSSHCommandInterface()) + $interface = new DrydockSSHCommandInterface(); + if ($resource->getAttribute('protocol') === 'winrm') { + $interface = new DrydockWinRMCommandInterface(); + } + + return $interface ->setConfiguration(array( 'host' => $resource->getAttribute('host'), 'port' => $resource->getAttribute('port'), diff --git a/src/applications/drydock/blueprint/windows/WindowsZeroConf.php b/src/applications/drydock/blueprint/windows/WindowsZeroConf.php --- a/src/applications/drydock/blueprint/windows/WindowsZeroConf.php +++ b/src/applications/drydock/blueprint/windows/WindowsZeroConf.php @@ -1,23 +1,33 @@ getUserData($credential)); + public function getEncodedUserData( + PassphraseCredential $credential, + $protocol) { + + return base64_encode($this->getUserData($credential, $protocol)); + } + + private function getSSHZeroConfScript() { + $file = + dirname(phutil_get_library_root('phabricator')). + '/resources/windows/sshzeroconf.ps1'; + return Filesystem::readFile($file); } - private function getZeroConfScript() { + private function getWinRMZeroConfScript() { $file = dirname(phutil_get_library_root('phabricator')). - '/resources/windows/zeroconf.ps1'; + '/resources/windows/winrmzeroconf.ps1'; return Filesystem::readFile($file); } - private function getUserData(PassphraseCredential $credential) { + private function getUserData(PassphraseCredential $credential, $protocol) { $type = PassphraseCredentialType::getTypeByConstant( $credential->getCredentialType()); @@ -25,34 +35,61 @@ throw new Exception(pht('Credential has invalid type "%s"!', $type)); } - if (!$type->hasPublicKey()) { - throw new Exception(pht('Credential has no public key!')); - } + if ($protocol === 'ssh') { + if (!$type->hasPublicKey()) { + throw new Exception(pht('Credential has no public key!')); + } - $username = $credential->getUsername(); - $publickey = $type->getPublicKey( - PhabricatorUser::getOmnipotentUser(), - $credential); - $publickey = trim($publickey); + $username = $credential->getUsername(); + $publickey = $type->getPublicKey( + PhabricatorUser::getOmnipotentUser(), + $credential); + $publickey = trim($publickey); - $username = str_replace('"', '`"', $username); - $publickey = str_replace('"', '`"', $publickey); + $username = str_replace('"', '`"', $username); + $publickey = str_replace('"', '`"', $publickey); - $start = << \$username = "$username"; \$publickey = "$publickey"; EOF; - $script = $this->getZeroConfScript(); + $script = $this->getZeroConfScript('ssh'); - $end = << EOF; - return $start.$script.$end; + return $start.$script.$end; + } else if ($protocol === 'winrm') { + $username = $credential->getUsername(); + $password = $credential->getSecret(); + + $username = str_replace('"', '`"', $username); + $password = str_replace('"', '`"', $password); + + $start = << +\$username = "$username"; +\$password = "$password"; + +EOF; + + $script = $this->getZeroConfScript('winrm'); + + $end = << +EOF; + + return $start.$script.$end; + + } else { + throw new Exception('Unknown protocol for automatic setup'); + } } } diff --git a/src/applications/drydock/check/DrydockSetupCheckWinRM.php b/src/applications/drydock/check/DrydockSetupCheckWinRM.php new file mode 100644 --- /dev/null +++ b/src/applications/drydock/check/DrydockSetupCheckWinRM.php @@ -0,0 +1,41 @@ + 'https://github.com/masterzen/winrm'), + 'https://github.com/masterzen/winrm'), + phutil_tag('tt', array(), 'PATH')); + + $message = pht( + 'You only need this binary if you are leasing Windows hosts in '. + 'Drydock or Harbormaster. If you don\'t need to run commands on '. + 'Windows machines, you can safely ignore this message.'); + + $this->newIssue('bin.winrm') + ->setShortName(pht("'%s' Missing", 'winrm')) + ->setName(pht("Missing '%s' Binary", 'winrm')) + ->setSummary( + pht("The '%s' binary could not be located or executed.", 'winrm')) + ->setMessage(pht("%s\n\n%s", $preamble, $message)); + } + + } + +} diff --git a/src/applications/drydock/interface/command/DrydockWinRMCommandInterface.php b/src/applications/drydock/interface/command/DrydockWinRMCommandInterface.php new file mode 100644 --- /dev/null +++ b/src/applications/drydock/interface/command/DrydockWinRMCommandInterface.php @@ -0,0 +1,76 @@ +passphraseWinRMPassword !== null) { + return; + } + + $credential = id(new PassphraseCredentialQuery()) + ->setViewer(PhabricatorUser::getOmnipotentUser()) + ->withIDs(array($this->getConfig('credential'))) + ->needSecrets(true) + ->executeOne(); + + if ($credential->getProvidesType() !== + PassphraseCredentialTypePassword::PROVIDES_TYPE) { + throw new Exception('Only password credentials are supported.'); + } + + $this->passphraseWinRMPassword = PassphrasePasswordKey::loadFromPHID( + $credential->getPHID(), + PhabricatorUser::getOmnipotentUser()); + } + + public function setExecTimeout($timeout) { + $this->execTimeout = $timeout; + return $this; + } + + public function getExecFuture($command) { + $this->openCredentialsIfNotOpen(); + + $argv = func_get_args(); + + $change_directory = ''; + if ($this->getWorkingDirectory() !== null) { + $change_directory .= 'cd '.$this->getWorkingDirectory().' & '; + } + + // Encode the command to run under Powershell. + $command = id(new PhutilCommandString($argv)) + ->setEscapingMode(PhutilCommandString::MODE_POWERSHELL); + + // When Microsoft says "Unicode" they don't mean UTF-8. + $command = mb_convert_encoding($command, 'UTF-16LE'); + $command = base64_encode($command); + + $powershell = + 'C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe'; + $powershell .= + ' -ExecutionPolicy Bypass'. + ' -NonInteractive'. + ' -InputFormat Text'. + ' -OutputFormat Text'. + ' -EncodedCommand '.$command; + + $future = new ExecFuture( + 'winrm '. + '-hostname=%s '. + '-username=%P '. + '-password=%P '. + '-port=%s '. + '%s', + $this->getConfig('host'), + $this->passphraseWinRMPassword->getUsernameEnvelope(), + $this->passphraseWinRMPassword->getPasswordEnvelope(), + $this->getConfig('port'), + $change_directory.$powershell); + $future->setTimeout($this->execTimeout); + return $future; + } +}