diff --git a/resources/windows/zeroconf.ps1 b/resources/windows/zeroconf.ps1 new file mode 100644 --- /dev/null +++ b/resources/windows/zeroconf.ps1 @@ -0,0 +1,113 @@ +# Allow running scripts on this system +Set-ExecutionPolicy -Force Bypass + +# Download Cygwin64 +wget https://cygwin.com/setup-x86_64.exe ` + -OutFile C:\setup-x86_64.exe + +# Run Cygwin64 install +C:\setup-x86_64.exe -s http://mirrors.kernel.org/sourceware/cygwin/ -P openssh ` + -q -a x86_64 -B -X -n -N -d + +# Wait for Cygwin install to finish +while ((Get-Process setup-x86_64 -ErrorAction SilentlyContinue) -ne $null) { + Write-Host "Waiting for Cygwin to finish installation..." + Sleep 5 +} + +# Setup SSHD +$env:PATH = $env:PATH + ";c:\cygwin64\bin" +C:\cygwin64\bin\chmod.exe +r /etc/passwd +C:\cygwin64\bin\chmod.exe +r /etc/group +C:\cygwin64\bin\chmod.exe a+x /var +C:\cygwin64\bin\bash.exe -c "mkpasswd > /etc/passwd" +C:\cygwin64\bin\bash.exe C:\cygwin64\bin\ssh-host-config -u $username -y -w ` + SSHPRIV1@ + +# Set up cmd.exe wrapping script +$cmdwrap = @" +#!/bin/bash + +cmd=$* +cmd=`${cmd:3} + +/cygdrive/c/Windows/system32/cmd.exe /C `$cmd +"@ +$cmdwrap = $cmdwrap.Replace("`r`n", "`n") +Set-Content -Path C:\cygwin64\bin\cmdwrap.sh -Value $cmdwrap + +C:\cygwin64\bin\bash.exe -c "mkpasswd > /etc/passwd" + +if (!(Test-Path C:\cygwin64\home\$username\.ssh)) { + mkdir C:\cygwin64\home\$username\.ssh +} + +$keyline = "$publickey Automatically Defined Key" + +Set-Content ` + -Path C:\cygwin64\home\$username\.ssh\authorized_keys ` + -Value $keyline + +New-NetFirewallRule -DisplayName "SSHD" ` + -Direction Inbound -Protocol TCP -LocalPort 22 -Action allow + +Set-Service sshd -StartupType Automatic +Set-ItemProperty -Path "Registry::HKLM\System\CurrentControlSet\Services\sshd" ` + -Name "DelayedAutostart" -Value 1 -Type DWORD + +# Change privilege mode. +$secpasswd = ConvertTo-SecureString "SSHPRIV1@" -AsPlainText -Force +$targetCred = New-Object System.Management.Automation.PSCredential($username, ` + $secpasswd) +Invoke-Command -ComputerName localhost -Credential $targetCred ` + -ScriptBlock { + $sshd_config = Get-Content -Path C:\cygwin64\etc\sshd_config + $sshd_config = $sshd_config.Replace( ` + "UsePrivilegeSeparation sandbox", ` + "UsePrivilegeSeparation no") + Set-Content -Path C:\cygwin64\etc\sshd_config -Value $sshd_config + } + +# Use cmd.exe as the initial prompt on SSH. +$passwd_config = Get-Content -Path C:\cygwin64\etc\passwd +$search = $null; +for ($i = 0; $i -lt $passwd_config.Count; $i++) { + if ($passwd_config[$i].Contains($username)) { + $passwd_config[$i] = $passwd_config[$i].Replace( ` + "/var/empty", ` + "/home/$username") + $passwd_config[$i] = $passwd_config[$i].Replace( ` + "/bin/bash", ` + "/bin/cmdwrap.sh") + write-host ("Looking at " + $passwd_config[$i]) + } +} +Set-Content -Path C:\cygwin64\etc\passwd -Value $passwd_config + +# 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 + +# Start SSH. +Start-Service sshd 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 @@ -572,6 +572,7 @@ 'DoorkeeperTagsController' => 'applications/doorkeeper/controller/DoorkeeperTagsController.php', 'DrydockAllocationContext' => 'applications/drydock/util/DrydockAllocationContext.php', 'DrydockAllocatorWorker' => 'applications/drydock/worker/DrydockAllocatorWorker.php', + 'DrydockAmazonEC2HostBlueprintImplementation' => 'applications/drydock/blueprint/DrydockAmazonEC2HostBlueprintImplementation.php', 'DrydockApacheWebrootInterface' => 'applications/drydock/interface/webroot/DrydockApacheWebrootInterface.php', 'DrydockBlueprint' => 'applications/drydock/storage/DrydockBlueprint.php', 'DrydockBlueprintController' => 'applications/drydock/controller/DrydockBlueprintController.php', @@ -2766,6 +2767,7 @@ 'UserQueryConduitAPIMethod' => 'applications/people/conduit/UserQueryConduitAPIMethod.php', 'UserRemoveStatusConduitAPIMethod' => 'applications/people/conduit/UserRemoveStatusConduitAPIMethod.php', 'UserWhoAmIConduitAPIMethod' => 'applications/people/conduit/UserWhoAmIConduitAPIMethod.php', + 'WindowsZeroConf' => 'applications/drydock/blueprint/windows/WindowsZeroConf.php', ), 'function' => array( '_phabricator_time_format' => 'view/viewutils.php', @@ -3333,6 +3335,7 @@ 'DoorkeeperTagsController' => 'PhabricatorController', 'DrydockAllocationContext' => 'Phobject', 'DrydockAllocatorWorker' => 'PhabricatorWorker', + 'DrydockAmazonEC2HostBlueprintImplementation' => 'DrydockMinMaxExpiryBlueprintImplementation', 'DrydockApacheWebrootInterface' => 'DrydockWebrootInterface', 'DrydockBlueprint' => array( 'DrydockDAO', @@ -5771,5 +5774,6 @@ 'UserQueryConduitAPIMethod' => 'UserConduitAPIMethod', 'UserRemoveStatusConduitAPIMethod' => 'UserConduitAPIMethod', 'UserWhoAmIConduitAPIMethod' => 'UserConduitAPIMethod', + 'WindowsZeroConf' => 'Phobject', ), )); diff --git a/src/applications/drydock/blueprint/DrydockAmazonEC2HostBlueprintImplementation.php b/src/applications/drydock/blueprint/DrydockAmazonEC2HostBlueprintImplementation.php new file mode 100644 --- /dev/null +++ b/src/applications/drydock/blueprint/DrydockAmazonEC2HostBlueprintImplementation.php @@ -0,0 +1,539 @@ +setAWSKeys( + PhabricatorEnv::getEnvConfig('amazon-ec2.access-key'), + PhabricatorEnv::getEnvConfig('amazon-ec2.secret-key')) + ->setAWSRegion($this->getDetail('region')); + } + + private function getAWSKeyPairName() { + return 'phabricator-'.$this->getDetail('keypair'); + } + + public function canAllocateResourceForLease(DrydockLease $lease) { + return + $lease->getAttribute('platform') === $this->getDetail('platform'); + } + + protected function canAllocateLease( + DrydockResource $resource, + DrydockLease $lease) { + return + $lease->getAttribute('platform') === $resource->getAttribute('platform'); + } + + protected function executeAllocateResource( + DrydockResource $resource, + DrydockLease $lease) { + + // We need to retrieve this as we need to use it for both importing the + // key and looking up the ID for the resource attributes. + $credential = id(new PassphraseCredentialQuery()) + ->setViewer(PhabricatorUser::getOmnipotentUser()) + ->withPHIDs(array($this->getDetail('keypair'))) + ->executeOne(); + + try { + $existing_keys = $this->getAWSEC2Future() + ->setRawAWSQuery( + 'DescribeKeyPairs', + array( + 'KeyName.0' => $this->getAWSKeyPairName())) + ->resolve(); + } catch (PhutilAWSException $ex) { + // The key pair does not exist, so we need to import it. + + $type = PassphraseCredentialType::getTypeByConstant( + $credential->getCredentialType()); + if (!$type) { + throw new Exception(pht('Credential has invalid type "%s"!', $type)); + } + + if (!$type->hasPublicKey()) { + throw new Exception(pht('Credential has no public key!')); + } + + $public_key = $type->getPublicKey( + PhabricatorUser::getOmnipotentUser(), + $credential); + + $this->getAWSEC2Future() + ->setRawAWSQuery( + 'ImportKeyPair', + array( + 'KeyName' => $this->getAWSKeyPairName(), + 'PublicKeyMaterial' => base64_encode($public_key))) + ->resolve(); + } + + $settings = array( + 'ImageId' => $this->getDetail('ami'), + 'MinCount' => 1, + 'MaxCount' => 1, + 'KeyName' => $this->getAWSKeyPairName(), + 'InstanceType' => $this->getDetail('size'), + 'SubnetId' => $this->getDetail('subnet-id') + ); + + $i = 0; + $security_groups = explode(',', $this->getDetail('security-group-ids')); + foreach ($security_groups as $security_group) { + $settings['SecurityGroupId.'.$i] = $security_group; + $i++; + } + + if (!$this->getDetail('skip-ssh-setup-windows')) { + if ($this->getDetail('platform') === 'windows') { + $settings['UserData'] = id(new WindowsZeroConf()) + ->getEncodedUserData($credential); + } + } + + $result = $this->getAWSEC2Future() + ->setRawAWSQuery( + 'RunInstances', + $settings) + ->resolve(); + + $instance = $result->instancesSet->item[0]; + $instance_id = (string)$instance->instanceId; + + // Allocate the resource and place it into Pending status while + // we wait for the instance to start. + $blueprint = $this->getInstance(); + $resource + ->setName($instance_id) + ->setStatus(DrydockResourceStatus::STATUS_PENDING) + ->setAttributes(array( + 'instance-id' => $instance_id, + 'platform' => $this->getDetail('platform'), + 'path' => $this->getDetail('storage-path'), + 'credential' => $credential->getID(), + 'aws-status' => 'Instance Requested')) + ->save(); + + // Wait until the instance has started. + while (true) { + try { + $result = $this->getAWSEC2Future() + ->setRawAWSQuery( + 'DescribeInstances', + array( + 'InstanceId.0' => $instance_id)) + ->resolve(); + + $reservation = $result->reservationSet->item[0]; + $instance = $reservation->instancesSet->item[0]; + $instance_state = (string)$instance->instanceState->name; + + if ($instance_state === 'pending') { + sleep(5); + continue; + } else if ($instance_state === 'running') { + break; + } else { + // Instance is shutting down or is otherwise terminated. + throw new Exception( + 'Allocated instance, but ended up in unexpected state \''. + $instance_state.'\'!'); + } + } catch (PhutilAWSException $ex) { + // TODO: This can happen because the instance doesn't exist yet, but + // we should check specifically for that error. + sleep(5); + continue; + } + } + + $resource->setAttribute('aws-status', 'Started in Amazon'); + $resource->save(); + + // Calculate the IP address of the instance. + $address = ''; + if ($this->getDetail('allocate-elastic-ip')) { + $resource->setAttribute('eip-status', 'Allocating Elastic IP'); + $resource->setAttribute('eip-allocated', true); + $resource->save(); + + try { + // Allocate, assign and use a public IP address. + $result = $this->getAWSEC2Future() + ->setRawAWSQuery( + 'AllocateAddress', + array( + 'Domain' => 'vpc')) + ->resolve(); + + $public_ip = (string)$result->publicIp; + $allocation_id = (string)$result->allocationId; + + $resource->setAttribute('eip-allocation-id', $allocation_id); + $resource->setAttribute('eip-status', 'Associating Elastic IP'); + $resource->save(); + + $result = $this->getAWSEC2Future() + ->setRawAWSQuery( + 'AssociateAddress', + array( + 'InstanceId' => $instance_id, + 'AllocationId' => $allocation_id)) + ->resolve(); + + $association_id = (string)$result->associationId; + + $resource->setAttribute('eip-association-id', $association_id); + $resource->setAttribute('eip-status', 'Associated'); + $resource->save(); + + $address = $public_ip; + } catch (PhutilAWSException $ex) { + // We can't allocate an Elastic IP (probably because we've reached + // the maximum allowed on the account). Terminate the EC2 instance + // we just started and fail the resource allocation. + $this->getAWSEC2Future() + ->setRawAWSQuery( + 'TerminateInstances', + array( + 'InstanceId.0' => $instance_id)) + ->resolve(); + + $resource->setAttribute( + 'aws-status', + 'Terminated'); + $resource->save(); + + throw new Exception( + 'Unable to allocate an elastic IP for the new EC2 instance. '. + 'Check your AWS account limits and ensure your limit on '. + 'elastic IP addresses is high enough to complete the '. + 'resource allocation'); + } + } else { + $resource->setAttribute('eip-allocated', false); + + // Use the private IP address. + $result = $this->getAWSEC2Future() + ->setRawAWSQuery( + 'DescribeInstances', + array( + 'InstanceId.0' => $instance_id)) + ->resolve(); + + $reservation = $result->reservationSet->item[0]; + $instance = $reservation->instancesSet->item[0]; + + $address = (string)$instance->privateIpAddress; + } + + // Update address and port attributes. + $resource->setAttribute('host', $address); + $resource->setAttribute('port', 22); + $resource->save(); + + // 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); + + $resource->setAttribute( + 'aws-status', + 'Waiting for successful SSH connection'); + $resource->save(); + + while (true) { + try { + $ssh->getExecFuture('echo "test"')->resolvex(); + break; + } catch (Exception $ex) { + + // Make sure the instance hasn't been terminated or shutdown while + // we've been trying to connect. + $result = $this->getAWSEC2Future() + ->setRawAWSQuery( + 'DescribeInstances', + array( + 'InstanceId.0' => $instance_id)) + ->resolve(); + + $reservation = $result->reservationSet->item[0]; + $instance = $reservation->instancesSet->item[0]; + $instance_state = (string)$instance->instanceState->name; + + if ($instance_state === 'shutting-down' || + $instance_state === 'terminated') { + + // Deallocate and release the public IP address if we allocated one. + if ($resource->getAttribute('eip-allocated')) { + try { + $this->getAWSEC2Future() + ->setRawAWSQuery( + 'DisassociateAddress', + array( + 'AssociationId' => + $resource->getAttribute('eip-association-id'))) + ->resolve(); + } catch (PhutilAWSException $ex) { + } + + try { + $this->getAWSEC2Future() + ->setRawAWSQuery( + 'ReleaseAddress', + array( + 'AllocationId' => + $resource->getAttribute('eip-allocation-id'))) + ->resolve(); + } catch (PhutilAWSException $ex) { + } + + $resource->setAttribute( + 'eip-status', + 'Released'); + $resource->save(); + } + + $resource->setAttribute( + 'aws-status', + 'Terminated'); + $resource->save(); + + throw new Exception( + 'Allocated instance, but ended up in unexpected state \''. + $instance_state.'\'!'); + } + + continue; + } + } + + // Update the resource into open status. + $resource->setStatus(DrydockResourceStatus::STATUS_OPEN); + $resource->setAttribute( + 'aws-status', + 'Ready for Use'); + $resource->save(); + return $resource; + } + + protected function executeCloseResource(DrydockResource $resource) { + + // Deallocate and release the public IP address if we allocated one. + if ($resource->getAttribute('eip-allocated')) { + $this->getAWSEC2Future() + ->setRawAWSQuery( + 'DisassociateAddress', + array( + 'AssociationId' => $resource->getAttribute('eip-association-id'))) + ->resolve(); + + $this->getAWSEC2Future() + ->setRawAWSQuery( + 'ReleaseAddress', + array( + 'AllocationId' => $resource->getAttribute('eip-allocation-id'))) + ->resolve(); + } + + // Terminate the EC2 instance. + $this->getAWSEC2Future() + ->setRawAWSQuery( + 'TerminateInstances', + array( + 'InstanceId.0' => $resource->getAttribute('instance-id'))) + ->resolve(); + + } + + protected function executeAcquireLease( + DrydockResource $resource, + DrydockLease $lease) { + + while ($resource->getStatus() == DrydockResourceStatus::STATUS_PENDING) { + // This resource is still being set up by another allocator, wait until + // it is set to open. + sleep(5); + $resource->reload(); + } + + $platform = $resource->getAttribute('platform'); + $path = $resource->getAttribute('path'); + + $lease_id = $lease->getID(); + + // Can't use DIRECTORY_SEPERATOR here because that is relevant to + // the platform we're currently running on, not the platform we are + // remoting to. + $separator = '/'; + if ($platform === 'windows') { + $separator = '\\'; + } + + // Clean up the directory path a little. + $base_path = rtrim($path, '/'); + $base_path = rtrim($base_path, '\\'); + $full_path = $base_path.$separator.$lease_id; + + $cmd = $lease->getInterface('command'); + + $cmd->execx('mkdir %s', $full_path); + + $lease->setAttribute('path', $full_path); + } + + protected function executeReleaseLease( + DrydockResource $resource, + DrydockLease $lease) { + + $cmd = $lease->getInterface('command'); + $path = $lease->getAttribute('path'); + + try { + if ($resource->getAttribute('platform') !== 'windows') { + $cmd->execx('rm -rf %s', $path); + } else { + $cmd->execx('rm -Recurse -Force %s', $path); + } + } catch (Exception $ex) { + // We try to clean up, but sometimes files are locked or still in + // use (this is far more common on Windows). There's nothing we can + // do about this, so we ignore it. + } + } + + public function getType() { + return 'host'; + } + + public function getInterface( + DrydockResource $resource, + DrydockLease $lease, + $type) { + + 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')); + case 'filesystem': + return id(new DrydockSFTPFilesystemInterface()) + ->setConfiguration(array( + 'host' => $resource->getAttribute('host'), + 'port' => $resource->getAttribute('port'), + 'credential' => $resource->getAttribute('credential'))); + } + + throw new Exception("No interface of type '{$type}'."); + } + + public function getFieldSpecifications() { + return array( + 'amazon' => array( + 'name' => pht('Amazon Configuration'), + 'type' => 'header' + ), + 'region' => array( + 'name' => pht('Region'), + 'type' => 'text', + 'required' => true, + 'caption' => pht('e.g. %s', 'us-west-1') + ), + 'ami' => array( + 'name' => pht('AMI (Amazon Image)'), + 'type' => 'text', + 'required' => true, + 'caption' => pht('e.g. %s', 'ami-7fd3ae4f') + ), + 'keypair' => array( + 'name' => pht('Key Pair'), + 'type' => 'credential', + 'required' => true, + 'credential.provides' + => PassphraseCredentialTypeSSHPrivateKey::PROVIDES_TYPE, + 'caption' => pht( + 'Only the public key component is transmitted to Amazon.') + ), + 'size' => array( + 'name' => pht('Instance Size'), + 'type' => 'text', + 'required' => true, + 'caption' => pht('e.g. %s', 't2.micro') + ), + 'platform' => array( + 'name' => pht('Platform Name'), + 'type' => 'text', + 'required' => true, + 'caption' => pht('e.g. %s or %s', 'windows', 'linux') + ), + 'subnet-id' => array( + 'name' => pht('VPC Subnet'), + 'type' => 'text', + 'required' => true, + 'caption' => pht('e.g. %s', 'subnet-2a67439b') + ), + 'security-group-ids' => array( + 'name' => pht('VPC Security Groups'), + 'type' => 'text', + 'required' => true, + 'caption' => pht('e.g. %s', 'sg-3fa3491f,sg-bg18dea2') + ), + 'storage-path' => array( + 'name' => pht('Storage Path'), + 'type' => 'text', + 'required' => true, + 'caption' => pht( + 'A writable location on the instance where new directories / files '. + 'can be created and data can be stored in.') + ), + 'allocate-elastic-ip' => array( + 'name' => pht('Allocate Public IP'), + 'type' => 'bool', + 'caption' => pht( + 'If Phabricator is running in the same subnet as the allocated '. + 'machines, then you do not need to turn this option on. If '. + 'phabricator is hosted externally to Amazon EC2, then enable this '. + 'option to automatically allocate and assign elastic IP addresses '. + 'to instances so that Phabricator can SSH to them from the '. + 'internet (instances are still only accessible by SSH key pairs)') + ), + 'skip-ssh-setup-windows' => array( + 'name' => pht('Skip SSH 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.') + ), + ) + parent::getFieldSpecifications(); + } + +} diff --git a/src/applications/drydock/blueprint/DrydockBlueprintImplementation.php b/src/applications/drydock/blueprint/DrydockBlueprintImplementation.php --- a/src/applications/drydock/blueprint/DrydockBlueprintImplementation.php +++ b/src/applications/drydock/blueprint/DrydockBlueprintImplementation.php @@ -347,6 +347,10 @@ return true; } + public function canAllocateResourceForLease(DrydockLease $lease) { + return true; + } + abstract protected function executeAllocateResource( DrydockResource $resource, DrydockLease $lease); diff --git a/src/applications/drydock/blueprint/windows/WindowsZeroConf.php b/src/applications/drydock/blueprint/windows/WindowsZeroConf.php new file mode 100644 --- /dev/null +++ b/src/applications/drydock/blueprint/windows/WindowsZeroConf.php @@ -0,0 +1,58 @@ +getUserData($credential)); + } + + private function getZeroConfScript() { + $file = + dirname(phutil_get_library_root('phabricator')). + '/resources/windows/zeroconf.ps1'; + return Filesystem::readFile($file); + } + + private function getUserData(PassphraseCredential $credential) { + + $type = PassphraseCredentialType::getTypeByConstant( + $credential->getCredentialType()); + if (!$type) { + throw new Exception(pht('Credential has invalid type "%s"!', $type)); + } + + 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 = str_replace('"', '`"', $username); + $publickey = str_replace('"', '`"', $publickey); + + $start = << +\$username = "$username"; +\$publickey = "$publickey"; + +EOF; + + $script = $this->getZeroConfScript(); + + $end = << +EOF; + + return $start.$script.$end; + } + +} diff --git a/src/applications/drydock/worker/DrydockAllocatorWorker.php b/src/applications/drydock/worker/DrydockAllocatorWorker.php --- a/src/applications/drydock/worker/DrydockAllocatorWorker.php +++ b/src/applications/drydock/worker/DrydockAllocatorWorker.php @@ -173,6 +173,12 @@ unset($blueprints[$key]); continue; } + + if ($candidate_blueprint->getType() !== + $lease->getResourceType()) { + unset($blueprints[$key]); + continue; + } } $this->logToDrydock( @@ -196,6 +202,11 @@ unset($blueprints[$key]); continue; } + + if (!$candidate_blueprint->canAllocateResourceForLease($lease)) { + unset($blueprints[$key]); + continue; + } } $this->logToDrydock(