diff --git a/src/Ssh/Ssh.AlcWrapper/Ssh.AlcWrapper.csproj b/src/Ssh/Ssh.AlcWrapper/Ssh.AlcWrapper.csproj new file mode 100644 index 000000000000..87b66684a620 --- /dev/null +++ b/src/Ssh/Ssh.AlcWrapper/Ssh.AlcWrapper.csproj @@ -0,0 +1,22 @@ + + + + Ssh + true + true + + + + + + $(LegacyAssemblyPrefix)$(PsModuleName) + $(LegacyAssemblyPrefix)$(PsModuleName).AlcWrapper + + + + + + + + + \ No newline at end of file diff --git a/src/Ssh/Ssh.AlcWrapper/SshServices.cs b/src/Ssh/Ssh.AlcWrapper/SshServices.cs new file mode 100644 index 000000000000..c8a986e18716 --- /dev/null +++ b/src/Ssh/Ssh.AlcWrapper/SshServices.cs @@ -0,0 +1,328 @@ +// ---------------------------------------------------------------------------------- +// +// Copyright Microsoft Corporation +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ---------------------------------------------------------------------------------- + +using Microsoft.Azure.Management.Compute; +using Microsoft.Azure.Management.Compute.Models; +using Microsoft.Azure.Management.Network; +using Microsoft.Azure.Management.HybridCompute; +using Microsoft.Azure.Management.HybridCompute.Models; +using Microsoft.Azure.Management.Internal.Resources.Utilities.Models; +using Microsoft.Azure.Commands.Common.Authentication; +using Microsoft.Azure.Commands.Common.Authentication.Abstractions; +using Microsoft.Azure.Commands.Common.Exceptions; +using System.Linq; +using Microsoft.Rest.Azure; +using Newtonsoft.Json.Linq; + +namespace Microsoft.Azure.Commands.Ssh +{ + /// + /// Client to make API calls to Azure Compute Resource Provider. + /// + internal class ComputeClient + { + public IComputeManagementClient ComputeManagementClient { get; private set; } + + public ComputeClient(IAzureContext context) + : this(AzureSession.Instance.ClientFactory.CreateArmClient( + context, AzureEnvironment.Endpoint.ResourceManager)) + { + } + + public ComputeClient(IComputeManagementClient computeManagementClient) + { + this.ComputeManagementClient = computeManagementClient; + } + } + + /// + /// Client to make API calls to Azure Compute Resource Provider. + /// + internal class NetworkClient + { + public INetworkManagementClient NetworkManagementClient { get; private set; } + + public NetworkClient(IAzureContext context) + : this(AzureSession.Instance.ClientFactory.CreateArmClient( + context, AzureEnvironment.Endpoint.ResourceManager)) + { + } + + public NetworkClient(INetworkManagementClient networkManagementClient) + { + this.NetworkManagementClient = networkManagementClient; + } + } + + /// + /// Client to make API calls to Azure Hybrid Compute Resource Provider. + /// + internal class HybridComputeClient + { + public IHybridComputeManagementClient HybridComputeManagementClient { get; private set; } + + public HybridComputeClient(IAzureContext context) + : this(AzureSession.Instance.ClientFactory.CreateArmClient( + context, AzureEnvironment.Endpoint.ResourceManager)) + { + } + + public HybridComputeClient(IHybridComputeManagementClient hybridComputeManagementClient) + { + this.HybridComputeManagementClient = hybridComputeManagementClient; + } + } + + /// + /// Class that provides utility methods that rely on external Azure Services. + /// + public sealed class SshAzureUtils + { + private ComputeClient _computeClient; + private NetworkClient _networkClient; + private HybridComputeClient _hybridClient; + private IAzureContext context; + + public SshAzureUtils(IAzureContext azureContext) + { + context = azureContext; + } + + private ComputeClient ComputeClient + { + get + { + if (_computeClient == null) + { + _computeClient = new ComputeClient(context); + } + + return _computeClient; + } + + set { _computeClient = value; } + } + + private NetworkClient NetworkClient + { + get + { + if (_networkClient == null) + { + _networkClient = new NetworkClient(context); + } + return _networkClient; + } + + set { _networkClient = value; } + } + + private HybridComputeClient HybridClient + { + get + { + if (_hybridClient == null) + { + _hybridClient = new HybridComputeClient(context); + } + return _hybridClient; + } + + set { _hybridClient = value; } + } + + private IVirtualMachinesOperations VirtualMachineClient + { + get + { + return ComputeClient.ComputeManagementClient.VirtualMachines; + } + } + + private IMachinesOperations ArcMachineClient + { + get + { + return HybridClient.HybridComputeManagementClient.Machines; + } + } + + /// + /// Gets the first public IP of an Azure Virtual Machine. + /// + /// Azure Virtual Machine Name + /// Resource Group Name + /// Virtual Machine Public IP + public string GetFirstPublicIp(string vmName, string rgName) + { + var result = this.VirtualMachineClient.GetWithHttpMessagesAsync( + rgName, vmName).GetAwaiter().GetResult(); + + VirtualMachine vm = result.Body; + + string publicIpAddress = null; + + foreach (var nicReference in vm.NetworkProfile.NetworkInterfaces) + { + ResourceIdentifier parsedNicId = new ResourceIdentifier(nicReference.Id); + string nicRg = parsedNicId.ResourceGroupName; + string nicName = parsedNicId.ResourceName; + + var nic = this.NetworkClient.NetworkManagementClient.NetworkInterfaces.GetWithHttpMessagesAsync( + nicRg, nicName).GetAwaiter().GetResult(); + + var publicIps = nic.Body.IpConfigurations.Where(ipconfig => ipconfig.PublicIPAddress != null).Select(ipconfig => ipconfig.PublicIPAddress); + foreach (var ip in publicIps) + { + ResourceIdentifier parsedIpId = new ResourceIdentifier(ip.Id); + var ipRg = parsedIpId.ResourceGroupName; + var ipName = parsedIpId.ResourceName; + var ipAddress = this.NetworkClient.NetworkManagementClient.PublicIPAddresses.GetWithHttpMessagesAsync( + ipRg, ipName).GetAwaiter().GetResult().Body; + + publicIpAddress = ipAddress.IpAddress; + + if (!string.IsNullOrEmpty(publicIpAddress)) + { + break; + } + } + + if (!string.IsNullOrEmpty(publicIpAddress)) + { + break; + } + } + return publicIpAddress; + } + + /// + /// Makes call to Azure to confirm the type of the target and if the resource + /// really exists in this context. + /// + /// Resource Name + /// Resource Group Name + /// + /// Either "Microsoft.HybridCompute/machine" or + /// "Microsoft.Compute/virtualMachines" + /// + /// + /// Either "Microsoft.HybridCompute/machine" or "Microsoft.Compute/virtualMachines". + /// If type of the target can't be confirmed, then throw a terminating error. + /// + public string DecideResourceType( + string vmName, + string rgName, + string ResourceType) + { + + string hybridExceptionMessage; + string computeExceptionMessage; + + if (ResourceType != null) + { + if (ResourceType.Equals("Microsoft.HybridCompute/machines")) + { + if (TryArcServer(vmName, rgName, out hybridExceptionMessage)) + { + return "Microsoft.HybridCompute/machines"; + } + + throw new AzPSCloudException("Failed to get Azure Arc Server. Error: " + hybridExceptionMessage); + } + else if (ResourceType.Equals("Microsoft.Compute/virtualMachines")) + { + if (TryAzureVM(vmName, rgName, out computeExceptionMessage)) + { + return "Microsoft.Compute/virtualMachines"; + } + + throw new AzPSCloudException("Failed to get Azure Arc Server. Error: " + computeExceptionMessage); + } + } + + bool isArc = TryArcServer(vmName, rgName, out hybridExceptionMessage); + bool isAzVM = TryAzureVM(vmName, rgName, out computeExceptionMessage); + + if (isArc && isAzVM) + { + throw new AzPSCloudException("A arc server and a azure vm with the same name. Please provide -ResourceType argument."); + } + else if (!isArc && !isAzVM) + { + throw new AzPSCloudException("Unable to determine the target machine type as azure vm or arc server. Errors: \n" + hybridExceptionMessage + "\n" + computeExceptionMessage); + } + else if (isArc) + { + return "Microsoft.HybridCompute/machines"; + } + + return "Microsoft.Compute/virtualMachines"; + } + + private bool TryAzureVM( + string vmName, + string rgName, + out string azexceptionMessage) + { + azexceptionMessage = null; + try + { + var result = this.VirtualMachineClient.GetWithHttpMessagesAsync( + rgName, vmName).GetAwaiter().GetResult(); + } + catch (CloudException exception) + { + if (exception.Response.StatusCode == System.Net.HttpStatusCode.NotFound || exception.Response.StatusCode == System.Net.HttpStatusCode.Forbidden) + { + JObject json = JObject.Parse(exception.Response.Content); + azexceptionMessage = (string)json["error"]["message"]; + return false; + } + + // Unexpected exception we can't handle. + throw; + } + + return true; + } + + private bool TryArcServer( + string vmName, + string rgName, + out string azexceptionMessage) + { + azexceptionMessage = null; + try + { + var result = this.ArcMachineClient.GetWithHttpMessagesAsync(rgName, vmName).GetAwaiter().GetResult(); + } + catch (ErrorResponseException exception) + { + if (exception.Response.StatusCode == System.Net.HttpStatusCode.NotFound || exception.Response.StatusCode == System.Net.HttpStatusCode.Forbidden) + { + JObject json = JObject.Parse(exception.Response.Content); + azexceptionMessage = (string)json["error"]["message"]; + return false; + } + + // Unexpected exception we can't handle. + throw; + } + + return true; + } + } + +} diff --git a/src/Ssh/Ssh.Test/Properties/AssemblyInfo.cs b/src/Ssh/Ssh.Test/Properties/AssemblyInfo.cs new file mode 100644 index 000000000000..44f614f84f04 --- /dev/null +++ b/src/Ssh/Ssh.Test/Properties/AssemblyInfo.cs @@ -0,0 +1,50 @@ +// ---------------------------------------------------------------------------------- +// +// Copyright Microsoft Corporation +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ---------------------------------------------------------------------------------- + +using System.Reflection; +using System.Runtime.InteropServices; +using Xunit; + +// General Information about an assembly is controlled through the following +// set of attributes. Change these attribute values to modify the information +// associated with an assembly. +[assembly: AssemblyTitle("Microsoft.Azure.Commands.Ssh.Test")] +[assembly: AssemblyDescription("")] +[assembly: AssemblyConfiguration("")] +[assembly: AssemblyCompany("")] +[assembly: AssemblyProduct("Microsoft.Azure.Commands.Ssh.Test")] +[assembly: AssemblyCopyright(Microsoft.WindowsAzure.Commands.Common.AzurePowerShell.AssemblyCopyright)] +[assembly: AssemblyTrademark("")] +[assembly: AssemblyCulture("")] + +// Setting ComVisible to false makes the types in this assembly not visible +// to COM components. If you need to access a type in this assembly from +// COM, set the ComVisible attribute to true on that type. +[assembly: ComVisible(false)] + +// The following GUID is for the ID of the typelib if this project is exposed to COM +[assembly: Guid("822d5889-438f-4c18-9c06-f5db728d417d")] + +// Version information for an assembly consists of the following four values: +// +// Major Version +// Minor Version +// Build Number +// Revision +// +// You can specify all the values or you can default the Build and Revision Numbers +// by using the '*' as shown below: +[assembly: AssemblyVersion("0.0.1")] +[assembly: AssemblyFileVersion("0.0.1")] +[assembly: CollectionBehavior(DisableTestParallelization = true)] diff --git a/src/Ssh/Ssh.Test/Resources/DummyKey.txt b/src/Ssh/Ssh.Test/Resources/DummyKey.txt new file mode 100644 index 000000000000..365f098c9617 --- /dev/null +++ b/src/Ssh/Ssh.Test/Resources/DummyKey.txt @@ -0,0 +1 @@ +AAAAB3NzaC1yc2EAAAADAQABAAABAQCvAtoiFV59d8kaHUh82Uy5bso5g6/XWQku6++zfWNon5mpy8SLWT2QKiTUeWuM86wOmBhcFbO/8Powov8bWzZRkgNKnwyPOL3Pp3uyq9maGbYcGeOS757hZgdmJMuly/o7tGnJu0w2Efp8eZdGmz1FZOi4p+s5+EWAL8jOCFwXEuZm+mjsCIrJVlfZ6ArvunQ/NS+2ld4c8xsxH/smAJHw34wXI143dytjOmQLN8AbGhD927CnNLcFdJ2edqTBC5U7Rw/hy5hac9xVt/McK00bqAVOhzxIcxk6JiyaMSaCgaILw1s6IiRiJcF/vCH2FYIhzRFPbOtYFBc1SH73rMz3 \ No newline at end of file diff --git a/src/Ssh/Ssh.Test/Ssh.Test.csproj b/src/Ssh/Ssh.Test/Ssh.Test.csproj new file mode 100644 index 000000000000..04b94fa27f26 --- /dev/null +++ b/src/Ssh/Ssh.Test/Ssh.Test.csproj @@ -0,0 +1,30 @@ + + + + Cdn + + + + + + $(LegacyAssemblyPrefix)$(PsModuleName)$(AzTestAssemblySuffix).ScenarioTests + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/Ssh/Ssh.sln b/src/Ssh/Ssh.sln new file mode 100644 index 000000000000..f73b58db9136 --- /dev/null +++ b/src/Ssh/Ssh.sln @@ -0,0 +1,78 @@ +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 16 +VisualStudioVersion = 16.0.31702.278 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Ssh", "Ssh\Ssh.csproj", "{42656543-77AD-4968-BA4B-BE0778705625}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Ssh.Test", "Ssh.Test\Ssh.Test.csproj", "{5E5BBB82-2D69-4A12-93AA-E5753F87AF03}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Accounts", "..\Accounts\Accounts\Accounts.csproj", "{142D7B0B-388A-4CEB-A228-7F6D423C5C2E}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Authentication", "..\Accounts\Authentication\Authentication.csproj", "{FF81DC73-B8EC-4082-8841-4FBF2B16E7CE}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Authenticators", "..\Accounts\Authenticators\Authenticators.csproj", "{F12822FD-CE14-4C35-80CC-5094C82955C9}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Authentication.ResourceManager", "..\Accounts\Authentication.ResourceManager\Authentication.ResourceManager.csproj", "{3E016018-D65D-4336-9F64-17DA97783AD0}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "ScenarioTest.ResourceManager", "..\..\tools\ScenarioTest.ResourceManager\ScenarioTest.ResourceManager.csproj", "{F83FBA8D-732D-437C-A0E2-02E45B01E123}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "TestFx", "..\..\tools\TestFx\TestFx.csproj", "{BC80A1D0-FFA4-43D9-AA74-799F5CB54B58}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "AuthenticationAssemblyLoadContext", "..\Accounts\AuthenticationAssemblyLoadContext\AuthenticationAssemblyLoadContext.csproj", "{9CB02F6D-A509-49F1-BE2F-11F537A6CABE}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Ssh.AlcWrapper", "Ssh.AlcWrapper\Ssh.AlcWrapper.csproj", "{104DF33D-4EC3-4C64-A0E7-FB4DEB7EFC98}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {42656543-77AD-4968-BA4B-BE0778705625}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {42656543-77AD-4968-BA4B-BE0778705625}.Debug|Any CPU.Build.0 = Debug|Any CPU + {42656543-77AD-4968-BA4B-BE0778705625}.Release|Any CPU.ActiveCfg = Release|Any CPU + {42656543-77AD-4968-BA4B-BE0778705625}.Release|Any CPU.Build.0 = Release|Any CPU + {5E5BBB82-2D69-4A12-93AA-E5753F87AF03}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {5E5BBB82-2D69-4A12-93AA-E5753F87AF03}.Debug|Any CPU.Build.0 = Debug|Any CPU + {5E5BBB82-2D69-4A12-93AA-E5753F87AF03}.Release|Any CPU.ActiveCfg = Release|Any CPU + {5E5BBB82-2D69-4A12-93AA-E5753F87AF03}.Release|Any CPU.Build.0 = Release|Any CPU + {142D7B0B-388A-4CEB-A228-7F6D423C5C2E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {142D7B0B-388A-4CEB-A228-7F6D423C5C2E}.Debug|Any CPU.Build.0 = Debug|Any CPU + {142D7B0B-388A-4CEB-A228-7F6D423C5C2E}.Release|Any CPU.ActiveCfg = Release|Any CPU + {142D7B0B-388A-4CEB-A228-7F6D423C5C2E}.Release|Any CPU.Build.0 = Release|Any CPU + {FF81DC73-B8EC-4082-8841-4FBF2B16E7CE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {FF81DC73-B8EC-4082-8841-4FBF2B16E7CE}.Debug|Any CPU.Build.0 = Debug|Any CPU + {FF81DC73-B8EC-4082-8841-4FBF2B16E7CE}.Release|Any CPU.ActiveCfg = Release|Any CPU + {FF81DC73-B8EC-4082-8841-4FBF2B16E7CE}.Release|Any CPU.Build.0 = Release|Any CPU + {F12822FD-CE14-4C35-80CC-5094C82955C9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {F12822FD-CE14-4C35-80CC-5094C82955C9}.Debug|Any CPU.Build.0 = Debug|Any CPU + {F12822FD-CE14-4C35-80CC-5094C82955C9}.Release|Any CPU.ActiveCfg = Release|Any CPU + {F12822FD-CE14-4C35-80CC-5094C82955C9}.Release|Any CPU.Build.0 = Release|Any CPU + {3E016018-D65D-4336-9F64-17DA97783AD0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {3E016018-D65D-4336-9F64-17DA97783AD0}.Debug|Any CPU.Build.0 = Debug|Any CPU + {3E016018-D65D-4336-9F64-17DA97783AD0}.Release|Any CPU.ActiveCfg = Release|Any CPU + {3E016018-D65D-4336-9F64-17DA97783AD0}.Release|Any CPU.Build.0 = Release|Any CPU + {F83FBA8D-732D-437C-A0E2-02E45B01E123}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {F83FBA8D-732D-437C-A0E2-02E45B01E123}.Debug|Any CPU.Build.0 = Debug|Any CPU + {F83FBA8D-732D-437C-A0E2-02E45B01E123}.Release|Any CPU.ActiveCfg = Release|Any CPU + {F83FBA8D-732D-437C-A0E2-02E45B01E123}.Release|Any CPU.Build.0 = Release|Any CPU + {BC80A1D0-FFA4-43D9-AA74-799F5CB54B58}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {BC80A1D0-FFA4-43D9-AA74-799F5CB54B58}.Debug|Any CPU.Build.0 = Debug|Any CPU + {BC80A1D0-FFA4-43D9-AA74-799F5CB54B58}.Release|Any CPU.ActiveCfg = Release|Any CPU + {BC80A1D0-FFA4-43D9-AA74-799F5CB54B58}.Release|Any CPU.Build.0 = Release|Any CPU + {9CB02F6D-A509-49F1-BE2F-11F537A6CABE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {9CB02F6D-A509-49F1-BE2F-11F537A6CABE}.Debug|Any CPU.Build.0 = Debug|Any CPU + {9CB02F6D-A509-49F1-BE2F-11F537A6CABE}.Release|Any CPU.ActiveCfg = Release|Any CPU + {9CB02F6D-A509-49F1-BE2F-11F537A6CABE}.Release|Any CPU.Build.0 = Release|Any CPU + {104DF33D-4EC3-4C64-A0E7-FB4DEB7EFC98}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {104DF33D-4EC3-4C64-A0E7-FB4DEB7EFC98}.Debug|Any CPU.Build.0 = Debug|Any CPU + {104DF33D-4EC3-4C64-A0E7-FB4DEB7EFC98}.Release|Any CPU.ActiveCfg = Release|Any CPU + {104DF33D-4EC3-4C64-A0E7-FB4DEB7EFC98}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {F9B3D96E-9680-40BE-A917-02EE655D6030} + EndGlobalSection +EndGlobal diff --git a/src/Ssh/Ssh/Az.Ssh.psd1 b/src/Ssh/Ssh/Az.Ssh.psd1 new file mode 100644 index 000000000000..3e1fe84400fe --- /dev/null +++ b/src/Ssh/Ssh/Az.Ssh.psd1 @@ -0,0 +1,131 @@ +# +# Module manifest for module 'Az.Ssh' +# +# Generated by: Microsoft Corporation +# +# Generated on: 8/22/2019 +# + +@{ + +# Script module or binary module file associated with this manifest. +# RootModule = '' + +# Version number of this module. +ModuleVersion = '0.0.1' + +# Supported PSEditions +CompatiblePSEditions = 'Core', 'Desktop' + +# ID used to uniquely identify this module +GUID = '91832aaa-dc11-4583-8239-bce5fd531604' + +# Author of this module +Author = 'Microsoft Corporation' + +# Company or vendor of this module +CompanyName = 'Microsoft Corporation' + +# Copyright statement for this module +Copyright = 'Microsoft Corporation. All rights reserved.' + +# Description of the functionality provided by this module +Description = 'Microsoft Azure PowerShell - cmdlets for connecting to Azure VMs using SSH in Windows PowerShell and PowerShell Core.' + +# Minimum version of the PowerShell engine required by this module +PowerShellVersion = '5.1' + +# Name of the PowerShell host required by this module +# PowerShellHostName = '' + +# Minimum version of the PowerShell host required by this module +# PowerShellHostVersion = '' + +# Minimum version of Microsoft .NET Framework required by this module. This prerequisite is valid for the PowerShell Desktop edition only. +DotNetFrameworkVersion = '4.7.2' + +# Minimum version of the common language runtime (CLR) required by this module. This prerequisite is valid for the PowerShell Desktop edition only. +# CLRVersion = '' + +# Processor architecture (None, X86, Amd64) required by this module +# ProcessorArchitecture = '' + +# Modules that must be imported into the global environment prior to importing this module +RequiredModules = @(@{ModuleName = 'Az.Accounts'; ModuleVersion = '2.7.1'; }) + +# Assemblies that must be loaded prior to importing this module +#RequiredAssemblies = '.\Microsoft.Azure.Management.Compute.dll', '.\Microsoft.Azure.Management.Network.dll', '.\Microsoft.Azure.Management.HybridCompute.dll' + +# Script files (.ps1) that are run in the caller's environment prior to importing this module. +# ScriptsToProcess = @() + +# Type files (.ps1xml) to be loaded when importing this module +# TypesToProcess = @() + +# Format files (.ps1xml) to be loaded when importing this module +# FormatsToProcess = @() + +# Modules to import as nested modules of the module specified in RootModule/ModuleToProcess +NestedModules = @('.\Microsoft.Azure.PowerShell.Cmdlets.Ssh.dll') + +# Functions to export from this module, for best performance, do not use wildcards and do not delete the entry, use an empty array if there are no functions to export. +FunctionsToExport = @() + +# Cmdlets to export from this module, for best performance, do not use wildcards and do not delete the entry, use an empty array if there are no cmdlets to export. +CmdletsToExport = 'Export-AzSshConfig', 'Enter-AzVM' +# Variables to export from this module +# VariablesToExport = @() + +# Aliases to export from this module, for best performance, do not use wildcards and do not delete the entry, use an empty array if there are no aliases to export. +AliasesToExport = @() + +# DSC resources to export from this module +# DscResourcesToExport = @() + +# List of all modules packaged with this module +# ModuleList = @() + +# List of all files packaged with this module +# FileList = @() + +# Private data to pass to the module specified in RootModule/ModuleToProcess. This may also contain a PSData hashtable with additional module metadata used by PowerShell. +PrivateData = @{ + + PSData = @{ + + # Tags applied to this module. These help with module discovery in online galleries. + Tags = 'Azure','ResourceManager','ARM','VM', 'SSH' + + # A URL to the license for this module. + LicenseUri = 'https://aka.ms/azps-license' + + # A URL to the main website for this project. + ProjectUri = 'https://github.com/Azure/azure-powershell' + + # A URL to an icon representing this module. + # IconUri = '' + + # ReleaseNotes of this module + ReleaseNotes = '' + + # Prerelease string of this module + # Prerelease = '' + + # Flag to indicate whether the module requires explicit user acceptance for install/update/save + # RequireLicenseAcceptance = $false + + # External dependent modules of this module + # ExternalModuleDependencies = @() + + } # End of PSData hashtable + + } # End of PrivateData hashtable + +# HelpInfo URI of this module +# HelpInfoURI = '' + +# Default prefix for commands exported from this module. Override the default prefix using Import-Module -Prefix. +# DefaultCommandPrefix = '' + +} + diff --git a/src/Ssh/Ssh/ChangeLog.md b/src/Ssh/Ssh/ChangeLog.md new file mode 100644 index 000000000000..19fa7feeca76 --- /dev/null +++ b/src/Ssh/Ssh/ChangeLog.md @@ -0,0 +1,19 @@ + \ No newline at end of file diff --git a/src/Ssh/Ssh/Common/RSACertGenerator.cs b/src/Ssh/Ssh/Common/RSACertGenerator.cs new file mode 100644 index 000000000000..a5f494b21f23 --- /dev/null +++ b/src/Ssh/Ssh/Common/RSACertGenerator.cs @@ -0,0 +1,47 @@ +// ---------------------------------------------------------------------------------- +// +// Copyright Microsoft Corporation +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ---------------------------------------------------------------------------------- + +namespace Microsoft.Azure.Commands.Ssh +{ + internal class RSACertGenerator + { + private const string RsaOpenSshPrefix = "ssh-rsa-cert-v01@openssh.com"; + + private string certBytes; + private string certFileContents; + + public string CertificateContents + { + get + { + if (string.IsNullOrEmpty(certFileContents)) + { + certFileContents = GetContents(certBytes); + } + + return certFileContents; + } + } + + public RSACertGenerator(string certBytes) + { + this.certBytes = certBytes; + } + + private string GetContents(string certBytes) + { + return string.Format("{0} {1}", RsaOpenSshPrefix, certBytes); + } + } +} diff --git a/src/Ssh/Ssh/Common/RSAParser.cs b/src/Ssh/Ssh/Common/RSAParser.cs new file mode 100644 index 000000000000..16bef24b5fc0 --- /dev/null +++ b/src/Ssh/Ssh/Common/RSAParser.cs @@ -0,0 +1,94 @@ +using System; +using System.Collections.Generic; + +namespace Microsoft.Azure.Commands.Ssh +{ + internal class RSAParser + { + public const string KeyType = "RSA"; + + private const string KeyTypeKey = "kty"; + private const string ModulusKey = "n"; + private const string ExponentKey = "e"; + + private string keyId; + + public string Modulus { get; private set; } + public string Exponent { get; private set; } + + public string KeyId + { + get + { + if (string.IsNullOrEmpty(keyId)) + { + keyId = GetKeyId(Modulus); + } + + return keyId; + } + } + + public Dictionary Jwk + { + get + { + return new Dictionary + { + { KeyTypeKey, KeyType }, + { ModulusKey, Modulus }, + { ExponentKey, Exponent } + }; + } + } + + public RSAParser(string publicKeyFileContents) + { + ParseKey(publicKeyFileContents); + } + + private void ParseKey(string publicKey) + { + string[] keyParts = publicKey.Split(' '); + + if (keyParts.Length != 3) + { + throw new ArgumentException(); + } + + Span keyBytes = Convert.FromBase64String(keyParts[1]); + + int read = 0; + Span firstLengthBytes = keyBytes.Slice(read, 4); + firstLengthBytes.Reverse(); + int length = Convert.ToInt32(BitConverter.ToUInt32(firstLengthBytes.ToArray(), 0)); + + read += 4; + Span algorithm = keyBytes.Slice(read, Convert.ToInt32(length)); + + read += length; + Span secondLengthBytes = keyBytes.Slice(read, 4); + secondLengthBytes.Reverse(); + length = Convert.ToInt32(BitConverter.ToUInt32(secondLengthBytes.ToArray(), 0)); + + read += 4; + Span exponent = keyBytes.Slice(read, length); + + read += length; + Span thirdLengthBytes = keyBytes.Slice(read, 4); + thirdLengthBytes.Reverse(); + length = Convert.ToInt32(BitConverter.ToUInt32(thirdLengthBytes.ToArray(), 0)); + + read += 4; + Span modulus = keyBytes.Slice(read, length); + + Exponent = Convert.ToBase64String(exponent.ToArray()); + Modulus = Convert.ToBase64String(modulus.ToArray()); + } + + private string GetKeyId(string modulus) + { + return modulus.GetHashCode().ToString(); + } + } +} \ No newline at end of file diff --git a/src/Ssh/Ssh/Common/SshBaseCmdlet.cs b/src/Ssh/Ssh/Common/SshBaseCmdlet.cs new file mode 100644 index 000000000000..6f918e46b61b --- /dev/null +++ b/src/Ssh/Ssh/Common/SshBaseCmdlet.cs @@ -0,0 +1,813 @@ +// ---------------------------------------------------------------------------------- +// +// Copyright Microsoft Corporation +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ---------------------------------------------------------------------------------- + +using Microsoft.Azure.Commands.Common.Authentication.Models; +using Microsoft.Azure.Commands.ResourceManager.Common; +using System.Management.Automation; +using Microsoft.WindowsAzure.Commands.Utilities.Common; +using Microsoft.Azure.Commands.Common.Authentication; +using System.Text.RegularExpressions; +using System.IO; +using System.Text; +using System.Runtime.InteropServices; +using System; +using System.Security.Cryptography; +using System.Diagnostics; +using System.Net; +using Microsoft.Azure.Commands.Common.Exceptions; +using System.Collections.Generic; +using Microsoft.Azure.Commands.Common.Authentication.Abstractions; +using Microsoft.Azure.Commands.Common.Authentication.Factories; +using Microsoft.Azure.Commands.Common.Authentication.Abstractions.Models; +using Microsoft.Azure.PowerShell.Cmdlets.Ssh.Common; +using Newtonsoft.Json; +using Azure.ResourceManager.HybridConnectivity.Models; +using Microsoft.Azure.Commands.ResourceManager.Common.ArgumentCompleters; +using Microsoft.Azure.Management.Internal.Resources.Utilities.Models; + + +namespace Microsoft.Azure.Commands.Ssh +{ + public abstract class SshBaseCmdlet : AzureRMCmdlet + { + + #region Constants + private const string clientProxyStorageUrl = "https://sshproxysa.blob.core.windows.net"; + private const string clientProxyRelease = "release01-11-21"; + private const string clientProxyVersion = "1.3.017634"; + + protected internal const string InteractiveParameterSet = "Interactive"; + protected internal const string ResourceIdParameterSet = "ResourceId"; + protected internal const string IpAddressParameterSet = "IpAddress"; + #endregion + + #region Fields + protected internal bool deleteKeys; + protected internal bool deleteCert; + protected internal string proxyPath; + protected internal string relayInfo; + protected internal DateTime relayInfoExpiration; + #endregion + + #region Properties + internal SshAzureUtils AzureUtils + { + get + { + if (azureUtils == null) + { + azureUtils = new SshAzureUtils(DefaultProfile.DefaultContext); + } + + return azureUtils; + } + + set { azureUtils = value; } + } + private SshAzureUtils azureUtils; + + internal RMProfileClient ProfileClient + { + get + { + if (profileClient == null) + { + profileClient = new RMProfileClient(DefaultProfile as AzureRmProfile); + } + return profileClient; + } + + set { profileClient = value; } + } + private RMProfileClient profileClient; + #endregion + + #region Parameters + /// + /// The name of the resource group. The name is case insensitive. + /// + [Parameter( + ParameterSetName = InteractiveParameterSet, + Mandatory = true, + ValueFromPipelineByPropertyName = true)] + [ResourceGroupCompleter] + [ValidateNotNullOrEmpty] + public string ResourceGroupName { get; set; } + + /// + /// The name of the target Azure Virtual Machine or Arc Server. + /// + [Parameter( + ParameterSetName = InteractiveParameterSet, + Mandatory = true, + ValueFromPipelineByPropertyName = true)] + [SshResourceNameCompleter(new string[] { "Microsoft.Compute/virtualMachines", "Microsoft.HybridCompute/machines" }, "ResourceGroupName")] + [ValidateNotNullOrEmpty] + public string Name { get; set; } + + /// + /// The IP address of the target Azure Virtual Machine. + /// + [Parameter( + ParameterSetName = IpAddressParameterSet, + Mandatory = true)] + [ValidateNotNullOrEmpty] + public string Ip { get; set; } + + /// + /// Id of the target Azure Virtual Machine or Arc Server. + /// + [Parameter( + ParameterSetName = ResourceIdParameterSet, + Mandatory = true, + ValueFromPipeline = true, + ValueFromPipelineByPropertyName = true)] + [ValidateNotNullOrEmpty] + [SshResourceIdCompleter(new string[] { "Microsoft.HybridCompute/machines", "Microsoft.Compute/virtualMachines" })] + public string ResourceId { get; set; } + + /// + /// Path to the generated config file. + /// + [Parameter( + ParameterSetName = InteractiveParameterSet, + Mandatory = true)] + [Parameter( + ParameterSetName = IpAddressParameterSet, + Mandatory = true)] + [Parameter( + ParameterSetName = ResourceIdParameterSet, + Mandatory = true)] + [ValidateNotNullOrEmpty] + public virtual string ConfigFilePath { get; set; } + + /// + /// Path to a Public Key File that will be signed by AAD to create certificate for AAD login. + /// Work on this. + /// + [Parameter(ParameterSetName = InteractiveParameterSet)] + [Parameter(ParameterSetName = IpAddressParameterSet)] + [Parameter(ParameterSetName = ResourceIdParameterSet)] + [ValidateNotNullOrEmpty] + public string PublicKeyFile { get; set; } + + /// + /// Path to a Private Key File. + /// Can be used for key based authentication when connecting to a LocalUser + /// Or it can be used in conjuction with a signed public key for AAD Login + /// Work on this as well + /// + [Parameter(ParameterSetName = InteractiveParameterSet)] + [Parameter(ParameterSetName = IpAddressParameterSet)] + [Parameter(ParameterSetName = ResourceIdParameterSet)] + [ValidateNotNullOrEmpty] + public string PrivateKeyFile { get; set; } + + /// + /// Gets or sets a flag that uses a Private Ip when connecting to an Azure Virtual Machine. + /// User must have a line of sight to the Private Ip. + /// + [Parameter(ParameterSetName = InteractiveParameterSet)] + [Parameter(ParameterSetName = ResourceIdParameterSet)] + [ValidateNotNullOrEmpty] + public SwitchParameter UsePrivateIp { get; set; } + + /// + /// Name of a local user in the target machine to connect to. + /// To connect using AAD certificates, don't use this argument. + /// + [Parameter(ParameterSetName = InteractiveParameterSet)] + [Parameter(ParameterSetName = IpAddressParameterSet)] + [Parameter(ParameterSetName = ResourceIdParameterSet)] + [ValidateNotNullOrEmpty] + public string LocalUser { get; set; } + + /// + /// Port number to connect to on the remote target host. + /// + [Parameter(ParameterSetName = InteractiveParameterSet)] + [Parameter(ParameterSetName = IpAddressParameterSet)] + [Parameter(ParameterSetName = ResourceIdParameterSet)] + [ValidateNotNullOrEmpty] + public string Port { get; set; } + + /// + /// Path to a local folder that contains the OpenSSH executables to be used by this cmdlet. + /// Defaults to pre-installed bits. In Windows: C:\Windows\System32\OpenSSH. + /// + [Parameter(ParameterSetName = InteractiveParameterSet)] + [Parameter(ParameterSetName = IpAddressParameterSet)] + [Parameter(ParameterSetName = ResourceIdParameterSet)] + [ValidateNotNullOrEmpty] + public string SshClientFolder { get; set; } + + /// + /// The Type of the target Azure Resource. + /// Either Microsoft.Compute/virtualMachines or Microsoft.HybridCompute/machines. + /// + [Parameter(ParameterSetName = InteractiveParameterSet)] + [PSArgumentCompleter("Microsoft.Compute/virtualMachines", "Microsoft.HybridCompute/machines")] + [ValidateNotNullOrEmpty] + public string ResourceType { get; set; } + + /// + /// Folder to install the SSH Proxy if connecting to an Arc Server. + /// Defaults to a .clientsshproxy folder created in the user's directory. + /// + [Parameter(ParameterSetName = InteractiveParameterSet)] + [Parameter(ParameterSetName = ResourceIdParameterSet)] + [ValidateNotNullOrEmpty] + public string SshProxyFolder { get; set; } + + /// + /// Certificate File for authentication when connecting to a Local User account. + /// + [Parameter(ParameterSetName = InteractiveParameterSet)] + [Parameter(ParameterSetName = IpAddressParameterSet)] + [Parameter(ParameterSetName = ResourceIdParameterSet)] + [ValidateNotNullOrEmpty] + public string CertificateFile { get; set; } + + /// + /// Additional SSH arguments. + /// + [Parameter(ParameterSetName = InteractiveParameterSet, ValueFromRemainingArguments = true)] + [Parameter(ParameterSetName = IpAddressParameterSet, ValueFromRemainingArguments = true)] + [Parameter(ParameterSetName = ResourceIdParameterSet, ValueFromRemainingArguments = true)] + [ValidateNotNullOrEmpty] + public virtual string[] SshArguments { get; set; } + + /// + /// Overwrite existing ConfigFile + /// + [Parameter(ParameterSetName = InteractiveParameterSet)] + [Parameter(ParameterSetName = IpAddressParameterSet)] + [Parameter(ParameterSetName = ResourceIdParameterSet)] + [ValidateNotNullOrEmpty] + public virtual SwitchParameter Overwrite { get; set; } + + /// + /// Folder where generated keys will be saved. + /// + [Parameter(ParameterSetName = InteractiveParameterSet)] + [Parameter(ParameterSetName = IpAddressParameterSet)] + [Parameter(ParameterSetName = ResourceIdParameterSet)] + [ValidateNotNullOrEmpty] + public virtual string KeysDestinationFolder { get; set; } + + [Parameter(Mandatory = false)] + public SwitchParameter PassThru { get; set; } + + #endregion + + #region Protected Internal Methods + + protected internal void BeforeExecution() + { + string a = getProxyPath(); + switch (ParameterSetName) + { + case IpAddressParameterSet: + ResourceType = "Microsoft.Compute/virtualMachines"; + break; + case ResourceIdParameterSet: + ResourceIdentifier parsedId = new ResourceIdentifier(ResourceId); + Name = parsedId.ResourceName; + ResourceGroupName = parsedId.ResourceGroupName; + ResourceType = AzureUtils.DecideResourceType(Name, ResourceGroupName, parsedId.ResourceType); + break; + case InteractiveParameterSet: + ResourceType = AzureUtils.DecideResourceType(Name, ResourceGroupName, ResourceType); + break; + } + + ValidateParameters(); + } + + protected internal void UpdateProgressBar( + ProgressRecord record, + string statusMessage, + int percentComplete) + { + record.PercentComplete = percentComplete; + record.StatusDescription = statusMessage; + WriteProgress(record); + } + + protected internal void GetRelayInformation() + { + Track2HybridConnectivityManagementClient myclient = new Track2HybridConnectivityManagementClient(AzureSession.Instance.ClientFactory, + DefaultProfile.DefaultContext); + TargetResourceEndpointAccess responseValue = null; + switch (ParameterSetName) + { + case InteractiveParameterSet: + responseValue = myclient.GetRelayInformationString(ResourceGroupName, Name, "default"); + break; + case ResourceIdParameterSet: + responseValue = myclient.GetRelayInformationString(ResourceId, "default"); + break; + } + + if (responseValue != null) + { + relayInfoExpiration = TimeZoneInfo.ConvertTime(DateTimeOffset.FromUnixTimeSeconds((long)responseValue.ExpiresOn), TimeZoneInfo.Local).DateTime; + relayInfo = Convert.ToBase64String(Encoding.UTF8.GetBytes("{\"relay\":" + JsonConvert.SerializeObject(responseValue) + "}")); + } + else + { + throw new AzPSInvalidOperationException("Unable to get Relay Information."); + } + + } + + protected internal void GetVmIpAddress() + { + Ip = AzureUtils.GetFirstPublicIp(Name, ResourceGroupName); + if (Ip == null) + { + string errorMessage = "Couldn't determine the IP address of " + Name + "in the Resource Group " + ResourceGroupName; + throw new AzPSResourceNotFoundCloudException(errorMessage); + } + } + + protected internal void PrepareAadCredentials(string credentialFolder = null) + { + deleteCert = true; + deleteKeys = CheckOrCreatePublicAndPrivateKeyFile(credentialFolder); + CertificateFile = GetAndWriteCertificate(PublicKeyFile); + LocalUser = GetSSHCertPrincipals(CertificateFile)[0]; + } + + protected internal string GetCertificateExpirationTimes() + { + string[] certificateInfo = GetSSHCertInfo(this.CertificateFile); + foreach (string line in certificateInfo) + { + if (line.Contains("Valid:")) + { + var validity = Regex.Split(line.Trim().Replace("Valid: from ", ""), " to "); + DateTime endDate = DateTime.Parse(validity[1]); + return endDate.ToString(); + } + + } + return null; + } + + protected internal string GetSSHClientPath(string sshCommand) + { + string sshPath; + string commandExecutable = sshCommand; + + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + commandExecutable += ".exe"; + } + if (SshClientFolder != null) + { + sshPath = Path.Combine(SshClientFolder, commandExecutable); + if (File.Exists(sshPath)) + { + WriteDebug("Attempting to run " + commandExecutable + " from path " + sshPath + "."); + return sshPath; + } + WriteWarning("Could not find " + sshPath + ". " + "Attempting to get pre-installed OpenSSH bits."); + } + + sshPath = commandExecutable; + + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + string systemDir = Environment.SystemDirectory; + sshPath = Path.Combine(systemDir, "openSSH", commandExecutable); + if (!File.Exists(sshPath)) + { + throw new AzPSFileNotFoundException("Couldn't find " + sshPath + " .\nMake sure OpenSSH is installed correctly: https://docs.microsoft.com/en-us/windows-server/administration/openssh/openssh_install_firstuse . Or use -SshClientFolder to provide folder path with ssh executables. ", sshPath); + } + } + return sshPath; + } + + protected internal string GetClientSideProxy() + { + string proxyPath = null; + string oldProxyPattern = null; + string requestUrl = null; + + GetProxyUrlAndFilename(ref proxyPath, ref oldProxyPattern, ref requestUrl); + + if (!File.Exists(proxyPath)) + { + string proxyDir = Path.GetDirectoryName(proxyPath); + Console.WriteLine(proxyDir); + + if (!Directory.Exists(proxyDir)) + { + Directory.CreateDirectory(proxyDir); + } + else + { + var files = Directory.GetFiles(proxyDir, oldProxyPattern); + foreach (string file in files) + { + try + { + File.Delete(file); + } + catch (Exception exception) + { + WriteWarning("Couldn't delete old version of the Proxy File: " + file + ". Error: " + exception.Message); + } + } + } + + try + { + WebClient wc = new WebClient(); + wc.DownloadFile(new Uri(requestUrl), proxyPath); + } + catch (Exception exception) + { + string errorMessage = "Failed to download client proxy executable from " + requestUrl + ". Error: " + exception.Message; + throw new AzPSApplicationException(errorMessage); + } + } + return proxyPath; + } + + protected internal void DeleteFile(string fileName, string warningMessage = null) + { + if (File.Exists(fileName)) + { + try + { + File.Delete(fileName); + } + catch (Exception e) + { + if (warningMessage != null) + { + WriteWarning(warningMessage + " Error: " + e.Message); + } + else + { + throw; + } + } + } + } + + protected internal void DeleteDirectory(string dirPath, string warningMessage = null) + { + if (Directory.Exists(dirPath)) + { + try + { + Directory.Delete(dirPath); + } + catch (Exception e) + { + if (warningMessage != null) + { + WriteWarning(warningMessage + " Error: " + e.Message); + } + else + { + throw; + } + } + } + } + + protected internal bool IsArc() + { + if (ResourceType.Equals("Microsoft.HybridCompute/machines")) + { + return true; + } + return false; + } + + #endregion + + + #region Private Methods + private void ValidateParameters() + { + if (CertificateFile != null && LocalUser != null) + { + WriteWarning("To authenticate with a cartificate you must provide a LocalUser. The certificate will be ignored."); + } + + if (PrivateKeyFile != null && !File.Exists(PrivateKeyFile)) + { + throw new AzPSArgumentException("Provided private key file doesn't exist.", nameof(PrivateKeyFile)); + } + + if (PublicKeyFile != null && !File.Exists(PublicKeyFile)) + { + throw new AzPSArgumentException("Provided private key file doesn't exist.", nameof(PublicKeyFile)); + } + + if (CertificateFile != null && !File.Exists(CertificateFile)) + { + throw new AzPSArgumentException("Provided private key file doesn't exist.", nameof(CertificateFile)); + } + + if (ConfigFilePath != null) + { + string configFolder = Path.GetDirectoryName(ConfigFilePath); + if (!Directory.Exists(configFolder)) + { + throw new AzPSArgumentException("Config file destination folder " + configFolder + " does not exist.", nameof(ConfigFilePath)); + } + } + + if (KeysDestinationFolder != null && (PrivateKeyFile != null || PublicKeyFile != null)) + { + throw new AzPSArgumentException("KeysDestinationFolder can't be used in conjunction with PublicKeyFile or PrivateKeyFile. All generated keys are saved in the same directory as provided keys.", nameof(KeysDestinationFolder)); + } + + if (!ResourceType.Equals("Microsoft.HybridCompute/machines") && SshProxyFolder != null) + { + WriteWarning("-SshProxyFolder is only used when connecting to Arc Servers. This argument will be ignored."); + } + } + + private string GetAndWriteCertificate(string publicKeyFile) + { + SshCredential certificate = GetAccessToken(publicKeyFile); + string token = certificate.Credential; + string keyDir = Path.GetDirectoryName(publicKeyFile); + string certpath = Path.Combine(keyDir, "id_rsa.pub-aadcert.pub"); + string cert_contents = "ssh-rsa-cert-v01@openssh.com " + token; + + File.WriteAllText(certpath, cert_contents); + + return certpath; + } + + private SshCredential GetAccessToken(string publicKeyFile) + { + string publicKeyText = File.ReadAllText(publicKeyFile); + RSAParser parser = new RSAParser(publicKeyText); + var context = DefaultProfile.DefaultContext; + RSAParameters parameters = new RSAParameters + { + Exponent = Base64UrlHelper.DecodeToBytes(parser.Exponent), + Modulus = Base64UrlHelper.DecodeToBytes(parser.Modulus) + }; + ISshCredentialFactory factory = new SshCredentialFactory(); + AzureSession.Instance.TryGetComponent(nameof(ISshCredentialFactory), out factory); + var token = factory.GetSshCredential(context, parameters); + return token; + } + + private List GetSSHCertPrincipals(string certFile) + { + string[] certInfo = GetSSHCertInfo(certFile); + List principals = new List(); + bool inPrincipals = false; + + foreach (string line in certInfo) + { + if (line.Contains(":")) + { + inPrincipals = false; + } + if (line.Contains("Principals: ")) + { + inPrincipals = true; + continue; + } + if (inPrincipals) + { + principals.Add(line.Trim()); + } + } + return principals; + } + + private string[] GetSSHCertInfo(string certFile) + { + string sshKeygenPath = GetSSHClientPath("ssh-keygen"); + string args = "-L -f " + certFile; + WriteDebug("Runnung ssh-keygen command: " + sshKeygenPath + " " + args); + Process keygen = new Process(); + keygen.StartInfo.FileName = sshKeygenPath; + keygen.StartInfo.Arguments = args; + keygen.StartInfo.UseShellExecute = false; + keygen.StartInfo.RedirectStandardOutput = true; + keygen.Start(); + string output = keygen.StandardOutput.ReadToEnd(); + keygen.WaitForExit(); + + string[] certInfo = output.Split('\n'); + + return certInfo; + } + + private bool CheckOrCreatePublicAndPrivateKeyFile(string credentialFolder=null) + { + bool deleteKeys = false; + if (PublicKeyFile == null && PrivateKeyFile == null) + { + deleteKeys = true; + if (credentialFolder == null) + { + credentialFolder = CreateTempFolder(); + } + else + { + //create all directories in the path unless they already exist + Directory.CreateDirectory(credentialFolder); + } + + PublicKeyFile = Path.Combine(credentialFolder, "id_rsa.pub"); + PrivateKeyFile = Path.Combine(credentialFolder, "id_rsa"); + CreateSSHKeyfile(PrivateKeyFile); + } + + if (PublicKeyFile == null) + { + if (PrivateKeyFile != null) + { + PublicKeyFile = PrivateKeyFile + ".pub"; + } + else + { + throw new AzPSArgumentNullException("Public key file not specified.", "PublicKeyFile"); + } + } + + if (!File.Exists(PublicKeyFile)) + { + throw new AzPSFileNotFoundException("Public key file not found", PublicKeyFile); + } + + // The private key is not required as the user may be using a keypair stored in ssh-agent + if (PrivateKeyFile != null && !File.Exists(PrivateKeyFile)) + { + throw new AzPSFileNotFoundException("Private key file not found", PrivateKeyFile); + } + + return deleteKeys; + } + + private void CreateSSHKeyfile(string privateKeyFile) + { + string args = "-f " + privateKeyFile + " -t rsa -q -N \"\""; + Process keygen = Process.Start(GetSSHClientPath("ssh-keygen"), args); + keygen.WaitForExit(); + } + + + private string CreateTempFolder() + { + //mix in some numbers as well? + //worry about the length of the name? + //should we be using get random filename + string prefix = "aadsshcert"; + var dirnameBuilder = new StringBuilder(); + Random random = new Random(); + string dirname; + do + { + dirnameBuilder.Clear(); + dirnameBuilder.Append(prefix); + for (int i = 0; i < 8; i++) + { + char randChar = (char)random.Next('a', 'a' + 26); + dirnameBuilder.Append(randChar); + } + dirname = Path.Combine(Path.GetTempPath(), dirnameBuilder.ToString()); + } while (Directory.Exists(dirname)); + + Directory.CreateDirectory(Path.Combine(Path.GetTempPath(), dirnameBuilder.ToString())); + + return dirname; + } + + private string getProxyPath() + { + string os; + string architecture; + + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + os = "windows"; + } + else if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) + { + os = "linux"; + } + else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) + { + os = "darwin"; + } + else + { + throw new AzPSApplicationException("Operating System not supported."); + } + + if (Environment.Is64BitProcess) + { + architecture = "amd64"; + } + else + { + architecture = "386"; + } + + string proxyName = "sshProxy_" + os + "_" + architecture; + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + proxyName = proxyName + ".exe"; + } + + string assemblyPath = this.GetType().Assembly.Location; + string assemblyDirectory = Path.GetDirectoryName(assemblyPath); + string proxyPath = Path.Combine(assemblyDirectory, proxyName); + + if (File.Exists(proxyPath)) + { + return proxyPath; + } + throw new AzPSApplicationException("Unable to find proxy. Reinstall extension?"); + + //Check for a hash to make sure the assembly is for real before returning + } + + private void GetProxyUrlAndFilename( + ref string proxyPath, + ref string oldProxyPattern, + ref string requestUrl) + { + string os; + string architecture; + + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + os = "windows"; + } + else if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) + { + os = "linux"; + } + else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) + { + os = "darwin"; + } + else + { + throw new AzPSApplicationException("Operating System not supported."); + } + + if (Environment.Is64BitProcess) + { + architecture = "amd64"; + } + else + { + architecture = "386"; + } + + string proxyName = "sshProxy_" + os + "_" + architecture; + requestUrl = clientProxyStorageUrl + "/" + clientProxyRelease + "/" + proxyName + "_" + clientProxyVersion; + + string installPath = proxyName + "_" + clientProxyVersion.Replace('.', '_'); + oldProxyPattern = proxyName + "*"; + + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + requestUrl = requestUrl + ".exe"; + installPath = installPath + ".exe"; + oldProxyPattern = oldProxyPattern + ".exe"; + } + if (SshProxyFolder == null) + { + proxyPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), Path.Combine(".clientsshproxy", installPath)); + } + else + { + proxyPath = Path.Combine(SshProxyFolder, installPath); + } + + } + #endregion + + } + +} diff --git a/src/Ssh/Ssh/Common/SshResourceIdCompleterAttribute.cs b/src/Ssh/Ssh/Common/SshResourceIdCompleterAttribute.cs new file mode 100644 index 000000000000..96e7ed376aa2 --- /dev/null +++ b/src/Ssh/Ssh/Common/SshResourceIdCompleterAttribute.cs @@ -0,0 +1,25 @@ +using System.Management.Automation; + +namespace Microsoft.Azure.PowerShell.Cmdlets.Ssh.Common +{ + internal class SshResourceIdCompleterAttribute : ArgumentCompleterAttribute + { + public SshResourceIdCompleterAttribute(string [] resourceTypes) : base(CreateScriptBlock(resourceTypes)) + { + } + + public static ScriptBlock CreateScriptBlock(string [] resourceTypes) + { + string script = "param($commandName, $parameterName, $wordToComplete, $commandAst, $fakeBoundParameter)\n"; + script += "$resourceIds = @()\n"; + foreach (var resourceType in resourceTypes) + { + script += $"$resourceType = \"{resourceType}\"\n" + + "$resourceIds = $resourceIds + [Microsoft.Azure.Commands.ResourceManager.Common.ArgumentCompleters.ResourceIdCompleterAttribute]::GetResourceIds($resourceType)\n"; + } + script += "$resourceIds | Where-Object { $_ -Like \"*$wordToComplete*\" } | Sort-Object | ForEach-Object { [System.Management.Automation.CompletionResult]::new($_, $_, 'ParameterValue', $_) }"; + var scriptBlock = ScriptBlock.Create(script); + return scriptBlock; + } + } +} diff --git a/src/Ssh/Ssh/Common/SshResourceNameCompleterAttribute.cs b/src/Ssh/Ssh/Common/SshResourceNameCompleterAttribute.cs new file mode 100644 index 000000000000..7042e0d75082 --- /dev/null +++ b/src/Ssh/Ssh/Common/SshResourceNameCompleterAttribute.cs @@ -0,0 +1,31 @@ +using System; +using System.Management.Automation; + +namespace Microsoft.Azure.PowerShell.Cmdlets.Ssh.Common +{ + internal class SshResourceNameCompleterAttribute : ArgumentCompleterAttribute + { + public SshResourceNameCompleterAttribute(string [] resourceTypes, params string [] parentResourceParameterNames): base(CreateScriptBlock(resourceTypes, parentResourceParameterNames)) + { + } + + public static ScriptBlock CreateScriptBlock(string [] resourceTypes, string [] parentResourceNames) + { + string script = "param($commandName, $parameterName, $wordToComplete, $commandAst, $fakeBoundParameter)\n" + + "$parentResources = @()\n"; + foreach (var parentResourceName in parentResourceNames) + { + script += String.Format("$parentResources += $fakeBoundParameter[\"{0}\"]\n", parentResourceName); + } + script += "$resources = @()\n"; + foreach (var resourceType in resourceTypes) + { + script += String.Format("$resourceType = \"{0}\"\n", resourceType) + + "$resources = $resources + [Microsoft.Azure.Commands.ResourceManager.Common.ArgumentCompleters.ResourceNameCompleterAttribute]::FindResources($resourceType, $parentResources)\n"; + } + script += "$resources | Where-Object { $_ -Like \"$wordToComplete*\" } | Sort-Object | ForEach-Object { [System.Management.Automation.CompletionResult]::new($_, $_, 'ParameterValue', $_) }"; + ScriptBlock scriptBlock = ScriptBlock.Create(script); + return scriptBlock; + } + } +} diff --git a/src/Ssh/Ssh/Common/Track2HybridConnectivityManagementClient.cs b/src/Ssh/Ssh/Common/Track2HybridConnectivityManagementClient.cs new file mode 100644 index 000000000000..329d3b6d3733 --- /dev/null +++ b/src/Ssh/Ssh/Common/Track2HybridConnectivityManagementClient.cs @@ -0,0 +1,64 @@ +using Azure.ResourceManager.HybridConnectivity; +using Azure.ResourceManager.HybridConnectivity.Models; +using Azure.ResourceManager; +using Azure; +using Azure.ResourceManager.Resources; +using Microsoft.Azure.Commands.Common.Authentication; +using Microsoft.Azure.Commands.Common.Authentication.Abstractions; +using Microsoft.Azure.Management.Internal.Resources.Utilities.Models; + +namespace Microsoft.Azure.PowerShell.Cmdlets.Ssh.Common +{ + internal class Track2HybridConnectivityManagementClient + { + private ArmClient _armClient; + private string _subscription; + private IClientFactory _clientFactory; + + public Track2HybridConnectivityManagementClient(IClientFactory clientFactory, IAzureContext context) + { + _clientFactory = clientFactory; + _armClient = _clientFactory.CreateArmClient(context, AzureEnvironment.Endpoint.ActiveDirectoryServiceEndpointResourceId); + _subscription = context.Subscription.Id; + } + + private ResourceGroupResource GetResourceGroup(string resourceGroupName) => + _armClient.GetResourceGroupResource(ResourceGroupResource.CreateResourceIdentifier(_subscription, resourceGroupName)); + + private EndpointResource CreateEndpoint(string resourceGroup, EndpointResourceData data) + { + ResourceGroupResource rg = GetResourceGroup(resourceGroup); + EndpointResourceCollection endpresources = rg.GetEndpointResources(); + EndpointResource a = endpresources.CreateOrUpdate(WaitUntil.Completed, "default", data).WaitForCompletion(); + return a; + } + + public TargetResourceEndpointAccess GetRelayInformationString(string resourceGroup, string resourceName, string endpoint) + { + return GetRelayInformationString($"/subscriptions/{_subscription}/resourceGroups/{resourceGroup}/providers/Microsoft.HybridCompute/machines/{resourceName}", endpoint); + } + + public TargetResourceEndpointAccess GetRelayInformationString(string ResourceId, string endpoint) + { + + EndpointResource myEndpoint = _armClient.GetEndpointResource(EndpointResource.CreateResourceIdentifier(ResourceId, endpoint)); + try + { + var myCred = myEndpoint.GetCredentials(3600); + return myCred.Value; + } + catch (RequestFailedException) + { + EndpointResourceData data = new EndpointResourceData(); + data.EndpointType = EndpointType.Default; + //data.ResourceId = EndpointResource.CreateResourceIdentifier(ResourceId, endpoint); + data.ResourceId = ResourceId; + ResourceIdentifier parsedId = new ResourceIdentifier(ResourceId); + CreateEndpoint(parsedId.ResourceGroupName, data); + var myCred = myEndpoint.GetCredentials(3600); + return myCred.Value; + } + } + + } +} diff --git a/src/Ssh/Ssh/Models/PSSshConfigEntry.cs b/src/Ssh/Ssh/Models/PSSshConfigEntry.cs new file mode 100644 index 000000000000..0b7249f2f346 --- /dev/null +++ b/src/Ssh/Ssh/Models/PSSshConfigEntry.cs @@ -0,0 +1,141 @@ +// ---------------------------------------------------------------------------------- +// +// Copyright Microsoft Corporation +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ---------------------------------------------------------------------------------- + +using System.Text; +using System.Collections.Generic; + +namespace Microsoft.Azure.Commands.Ssh.Models +{ + /// + /// Class that represents an Ssh Configuration file to connect to Azure Resources. + /// + internal class PSSshConfigEntry + { + /// + /// Alias of the host in the config file. + /// If we know Resource Name and Resource Group, host will be "{rg}-{name}" + /// If we only know the Ip, host will be "{Ip}" + /// + public string Host { get; set; } + + /// + /// Actual host name. Ip address for Azure VMs and resource name for arc servers. + /// + public string HostName { get; set; } + + /// + /// Username. + /// + public string User { get; set; } + + /// + /// Path to certificate file. + /// + public string CertificateFile { get; set; } + + /// + /// Path to private key file. + /// + public string IdentityFile { get; set; } + + /// + /// Microsoft.HybridCompute/machines or Microsoft.Compute/virtualMachines. + /// + public string ResourceType { get; set; } + + /// + /// Command to connect to host via Arc Connectivity Proxy. + /// + public string ProxyCommand { get; set; } + + /// + /// Ssh Port. + /// + public string Port { get; set; } + + /// + /// Either AAD or LocalUser + /// + public string LoginType { get; set; } + + /// + /// String built from the value of this object's properties. + /// Note 1: If LocalUser login, "-{localusername}" is appended to host. + /// Note 2: Azure VMs have two entries if resource name and group are known. + /// One with {rg}-{name} as host, and one with {ip} as host. + /// + public string ConfigString + { + get + { + StringBuilder builder = new StringBuilder(); + if (LoginType.Equals("AAD")) { builder.AppendLine($"Host {this.Host}"); } + else { builder.AppendLine($"Host {this.Host}-{this.User}"); } + + if (!HostName.Equals(Host)) + { + builder.AppendLine($"\tHostName {this.HostName}"); + } + builder.AppendLine(string.Format("\tUser {0}", this.User)); + this.AppendKeyValuePairToStringBuilderIfNotValueNull("CertificateFile", this.CertificateFile, builder); + this.AppendKeyValuePairToStringBuilderIfNotValueNull("IdentityFile", this.IdentityFile, builder); + this.AppendKeyValuePairToStringBuilderIfNotValueNull("Port", this.Port, builder); + this.AppendKeyValuePairToStringBuilderIfNotValueNull("ProxyCommand", this.ProxyCommand, builder); + + if (ResourceType.Equals("Microsoft.Compute/virtualMachines") && !HostName.Equals(Host)) + { + if (LoginType.Equals("AAD")) { builder.AppendLine($"\nHost {this.HostName}"); } + else { builder.AppendLine($"\nHost {this.HostName}-{this.User}"); } + + builder.AppendLine(string.Format("\tUser {0}", this.User)); + this.AppendKeyValuePairToStringBuilderIfNotValueNull("CertificateFile", this.CertificateFile, builder); + this.AppendKeyValuePairToStringBuilderIfNotValueNull("IdentityFile", this.IdentityFile, builder); + this.AppendKeyValuePairToStringBuilderIfNotValueNull("Port", this.Port, builder); + } + + return builder.ToString(); + } + } + + public PSSshConfigEntry(Dictionary configEntry) + { + this.Host = GetPropertyValueFromConfigDictionary(configEntry, "Host"); + this.HostName = GetPropertyValueFromConfigDictionary(configEntry, "HostName"); + this.ProxyCommand = GetPropertyValueFromConfigDictionary(configEntry, "ProxyCommand"); + this.Port = GetPropertyValueFromConfigDictionary(configEntry, "Port"); + this.User = GetPropertyValueFromConfigDictionary(configEntry, "User"); + this.IdentityFile = GetPropertyValueFromConfigDictionary(configEntry, "IdentityFile"); + this.CertificateFile = GetPropertyValueFromConfigDictionary(configEntry, "CertificateFile"); + this.ResourceType = GetPropertyValueFromConfigDictionary(configEntry, "ResourceType"); + this.LoginType = GetPropertyValueFromConfigDictionary(configEntry, "LoginType"); + } + + private string GetPropertyValueFromConfigDictionary(Dictionary configEntry, string KeyName) + { + if (configEntry.ContainsKey(KeyName)) + { + return configEntry[KeyName]; + } + return null; + } + + private void AppendKeyValuePairToStringBuilderIfNotValueNull(string key, string value, StringBuilder builder) + { + if (value != null) + { + builder.AppendLine($"\t{key} {value}"); + } + } + } +} diff --git a/src/Ssh/Ssh/Properties/AssemblyInfo.cs b/src/Ssh/Ssh/Properties/AssemblyInfo.cs new file mode 100644 index 000000000000..223eab3734c5 --- /dev/null +++ b/src/Ssh/Ssh/Properties/AssemblyInfo.cs @@ -0,0 +1,29 @@ +// ---------------------------------------------------------------------------------- +// +// Copyright Microsoft Corporation +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ---------------------------------------------------------------------------------- + +using System; +using System.Reflection; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; + +[assembly: AssemblyTitle("Microsoft Azure Powershell - SSH")] +[assembly: AssemblyCompany(Microsoft.WindowsAzure.Commands.Common.AzurePowerShell.AssemblyCompany)] +[assembly: AssemblyProduct(Microsoft.WindowsAzure.Commands.Common.AzurePowerShell.AssemblyProduct)] +[assembly: AssemblyCopyright(Microsoft.WindowsAzure.Commands.Common.AzurePowerShell.AssemblyCopyright)] + +[assembly: ComVisible(false)] +[assembly: CLSCompliant(false)] +[assembly: Guid("d90791a2-8102-47fc-8150-de25ae796eb1")] +[assembly: AssemblyVersion("0.0.1")] +[assembly: AssemblyFileVersion("0.0.1")] diff --git a/src/Ssh/Ssh/Properties/Resources.Designer.cs b/src/Ssh/Ssh/Properties/Resources.Designer.cs new file mode 100644 index 000000000000..ea3ebba3b740 --- /dev/null +++ b/src/Ssh/Ssh/Properties/Resources.Designer.cs @@ -0,0 +1,63 @@ +//------------------------------------------------------------------------------ +// +// This code was generated by a tool. +// Runtime Version:4.0.30319.42000 +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +//------------------------------------------------------------------------------ + +namespace Microsoft.Azure.PowerShell.Cmdlets.Ssh.Properties { + using System; + + + /// + /// A strongly-typed resource class, for looking up localized strings, etc. + /// + // This class was auto-generated by the StronglyTypedResourceBuilder + // class via a tool like ResGen or Visual Studio. + // To add or remove a member, edit your .ResX file then rerun ResGen + // with the /str option, or rebuild your VS project. + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "16.0.0.0")] + [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] + [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] + internal class Resources { + + private static global::System.Resources.ResourceManager resourceMan; + + private static global::System.Globalization.CultureInfo resourceCulture; + + [global::System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode")] + internal Resources() { + } + + /// + /// Returns the cached ResourceManager instance used by this class. + /// + [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] + internal static global::System.Resources.ResourceManager ResourceManager { + get { + if (object.ReferenceEquals(resourceMan, null)) { + global::System.Resources.ResourceManager temp = new global::System.Resources.ResourceManager("Microsoft.Azure.PowerShell.Cmdlets.Ssh.Properties.Resources", typeof(Resources).Assembly); + resourceMan = temp; + } + return resourceMan; + } + } + + /// + /// Overrides the current thread's CurrentUICulture property for all + /// resource lookups using this strongly typed resource class. + /// + [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] + internal static global::System.Globalization.CultureInfo Culture { + get { + return resourceCulture; + } + set { + resourceCulture = value; + } + } + } +} diff --git a/src/Ssh/Ssh/Properties/Resources.resx b/src/Ssh/Ssh/Properties/Resources.resx new file mode 100644 index 000000000000..1af7de150c99 --- /dev/null +++ b/src/Ssh/Ssh/Properties/Resources.resx @@ -0,0 +1,120 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + \ No newline at end of file diff --git a/src/Ssh/Ssh/Ssh.csproj b/src/Ssh/Ssh/Ssh.csproj new file mode 100644 index 000000000000..c0196a6b6861 --- /dev/null +++ b/src/Ssh/Ssh/Ssh.csproj @@ -0,0 +1,62 @@ + + + + Ssh + + + + + Microsoft.Azure.Commands.Ssh.AlcWrapper + + + + + + + + + + + + + + + + + + false + runtime + + + + + + ..\..\..\..\azure-sdk-for-net\artifacts\bin\Azure.ResourceManager.HybridConnectivity\Release\netstandard2.0\Azure.ResourceManager.HybridConnectivity.dll + + + + + + True + True + Resources.resx + + + + + + ResXFileCodeGenerator + Resources.Designer.cs + + + + + + + + + + + + \ No newline at end of file diff --git a/src/Ssh/Ssh/SshCommands/EnterAzVMCommand.cs b/src/Ssh/Ssh/SshCommands/EnterAzVMCommand.cs new file mode 100644 index 000000000000..e36d4cd4868d --- /dev/null +++ b/src/Ssh/Ssh/SshCommands/EnterAzVMCommand.cs @@ -0,0 +1,233 @@ +// ---------------------------------------------------------------------------------- +// +// Copyright Microsoft Corporation +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ---------------------------------------------------------------------------------- + +using System; +using System.IO; +using System.Management.Automation; +using Microsoft.Azure.Commands.Common.Exceptions; +using System.Diagnostics; +using System.Collections.Generic; + + +namespace Microsoft.Azure.Commands.Ssh +{ + [Cmdlet( + VerbsCommon.Enter, + ResourceManager.Common.AzureRMConstants.AzureRMPrefix + "VM", + DefaultParameterSetName = InteractiveParameterSet)] + [OutputType(typeof(bool))] + [Alias("Enter-AzArcServer")] + public sealed class EnterAzVMCommand : SshBaseCmdlet + { + #region Supress Export-AzSshConfig Parameters + + public override string ConfigFilePath + { + get { return null; } + } + public override SwitchParameter Overwrite + { + get { return false; } + } + + public override string KeysDestinationFolder + { + get { return null; } + } + + #endregion + + public override void ExecuteCmdlet() + { + base.ExecuteCmdlet(); + + BeforeExecution(); + + ProgressRecord record = new ProgressRecord(0, "Prepare for starting SSH connection", "Start Preparing"); + UpdateProgressBar(record, "Start preparing", 0); + + if (!IsArc() && !ParameterSetName.Equals(IpAddressParameterSet)) + { + GetVmIpAddress(); + UpdateProgressBar(record, "Retrieved the IP address of the target VM", 50); + } + if (IsArc()) + { + proxyPath = GetClientSideProxy(); + UpdateProgressBar(record, "Dowloaded SSH Proxy, saved to " + proxyPath, 25); + GetRelayInformation(); + UpdateProgressBar(record, "Retrieved Relay Information" + proxyPath, 50); + } + try + { + if (LocalUser == null) + { + PrepareAadCredentials(); + } + + record.RecordType = ProgressRecordType.Completed; + UpdateProgressBar(record, "Ready to start SSH connection.", 100); + + int sshStatus = StartSSHConnection(); + if (this.PassThru.IsPresent) + { + WriteObject(sshStatus == 0); + } + } + finally + { + DoCleanup(); + } + } + + #region Private Methods + + private bool IsDebugMode() + { + bool debug; + bool containsDebug = MyInvocation.BoundParameters.ContainsKey("Debug"); + if (containsDebug) + debug = ((SwitchParameter)MyInvocation.BoundParameters["Debug"]).ToBool(); + else + debug = (ActionPreference)GetVariableValue("DebugPreference") != ActionPreference.SilentlyContinue; + return debug; + } + + private int StartSSHConnection() + { + string sshClient = GetSSHClientPath("ssh"); + // Process is not accepting ArgumentList instead of Arguments. .NET framework too old? + string command = GetHost() + " " + BuildArgs(); + + Process sshProcess = new Process(); + WriteDebug("Running SSH command: " + sshClient + " " + command); + + if (IsArc()) + sshProcess.StartInfo.EnvironmentVariables["SSHPROXY_RELAY_INFO"] = relayInfo; + sshProcess.StartInfo.FileName = sshClient; + sshProcess.StartInfo.Arguments = command; + sshProcess.StartInfo.RedirectStandardError = true; + sshProcess.StartInfo.UseShellExecute = false; + sshProcess.Start(); + + bool writeLogs = false; + var stderr = sshProcess.StandardError; + + if (SshArguments != null && + (Array.Exists(SshArguments, x => x == "-v") || + Array.Exists(SshArguments, x => x == "-vv") || + Array.Exists(SshArguments, x => x == "-vvv")) || + IsDebugMode()) + { + writeLogs = true; + } + + string line; + while ((line = stderr.ReadLine()) != null) + { + if (writeLogs || (!line.Contains("debug1: ") && !line.Contains("debug2: ") && !line.Contains("debug3: "))) + { + // We have the option of filtering out some of the logs that we don't want printed here. + // Console.Error.WriteLine(line); + // Logs are written to StdErr on OpenSSH + Host.UI.WriteLine(line); + } + if (line.Contains("debug1: Entering interactive session.")) + { + DoCleanup(); + } + //check for well known errors: azcmagent config not set to listen to port 22, OpenSSH too old error + } + + sshProcess.WaitForExit(); + + return sshProcess.ExitCode; + } + + private string GetHost() + { + if (ResourceType == "Microsoft.HybridCompute/machines" && LocalUser != null && Name != null) + { + return LocalUser + "@" + Name; + } else if (ResourceType == "Microsoft.Compute/virtualMachines" && LocalUser != null && Ip != null) + { + return LocalUser + "@" + Ip; + } + throw new AzPSInvalidOperationException("Unable to determine target host."); + } + + + private string BuildArgs() + { + List argList = new List(); + + if (PrivateKeyFile != null) { argList.Add("-i \"" + PrivateKeyFile + "\""); } + + if (CertificateFile != null) { argList.Add("-o CertificateFile=\"" + CertificateFile + "\""); } + + if (IsArc()) + { + string pcommand = "ProxyCommand=\"" + proxyPath + "\""; + if (Port != null) + { + pcommand = "ProxyCommand=\"" + proxyPath + " -p " + Port + "\""; + } + argList.Add("-o " + pcommand); + } else if (Port != null) + { + argList.Add("-p " + Port); + } + + if (SshArguments == null || + (!Array.Exists(SshArguments, x => x == "-v") && + !Array.Exists(SshArguments, x => x == "-vv") && + !Array.Exists(SshArguments, x => x == "-vvv"))) + { + if (IsDebugMode()) + argList.Add("-vvv"); + else + argList.Add("-v"); + } + + if (SshArguments != null) + { + Array.ForEach(SshArguments, item => argList.Add(item)); + } + + return string.Join(" ", argList.ToArray()); + } + + private void DoCleanup() + { + if (deleteKeys && PrivateKeyFile != null) + { + DeleteFile(PrivateKeyFile, "Couldn't delete Private Key file " + PrivateKeyFile + "."); + } + if (deleteKeys && PublicKeyFile != null) + { + DeleteFile(PublicKeyFile, "Couldn't delete Public Key file " + PublicKeyFile + "."); + } + if (deleteCert && CertificateFile != null) + { + DeleteFile(CertificateFile, "Couldn't delete Certificate File " + CertificateFile + "."); + } + if (deleteKeys) + { + DeleteDirectory(Directory.GetParent(CertificateFile).ToString()); + } + } + + #endregion + } +} diff --git a/src/Ssh/Ssh/SshCommands/ExportAzSshConfig.cs b/src/Ssh/Ssh/SshCommands/ExportAzSshConfig.cs new file mode 100644 index 000000000000..0d19ed39b0de --- /dev/null +++ b/src/Ssh/Ssh/SshCommands/ExportAzSshConfig.cs @@ -0,0 +1,158 @@ +using System.IO; +using Microsoft.Azure.Commands.Ssh.Models; +using System.Management.Automation; +using Microsoft.Azure.Commands.Common.Exceptions; +using System.Runtime.InteropServices; +using System.Text.RegularExpressions; +using System.Collections.Generic; + +namespace Microsoft.Azure.Commands.Ssh +{ + [Cmdlet("Export", + ResourceManager.Common.AzureRMConstants.AzureRMPrefix + "SshConfig")] + //[OutputType(typeof(PSSshConfigEntry))] + [OutputType(typeof(bool))] + public sealed class ExportAzSshConfig : SshBaseCmdlet + { + #region Supress Enter-AzVM Parameters + public override string[] SshArguments + { + get + { + return null; + } + } + #endregion + + #region Properties + internal string RelayInfoPath { get; set; } + #endregion + + public override void ExecuteCmdlet() + { + base.ExecuteCmdlet(); + + BeforeExecution(); + + ConfigFilePath = Path.GetFullPath(ConfigFilePath); + + ProgressRecord record = new ProgressRecord(0, "Creating SSH Config", "Start Preparing"); + UpdateProgressBar(record, "Start Preparing", 0); + + if (!IsArc() && !ParameterSetName.Equals(IpAddressParameterSet)) + { + GetVmIpAddress(); + UpdateProgressBar(record, "Retrieved target IP address", 50); + } + if (IsArc()) + { + proxyPath = GetClientSideProxy(); + UpdateProgressBar(record, "Downloaded proxy to " + proxyPath, 25); + GetRelayInformation(); + UpdateProgressBar(record, "Completed retrieving relay information", 50); + CreateRelayInfoFile(); + UpdateProgressBar(record, "Created file containing relay information", 65); + } + if (LocalUser == null) + { + PrepareAadCredentials(GetKeysDestinationFolder()); + UpdateProgressBar(record, "Generated Certificate File", 90); + // This is not exactly a warning. But I couldn't make WriteInformation or WriteObject work. + WriteWarning($"Generated AAD Certificate {CertificateFile} is valid until {GetCertificateExpirationTimes()} in local time."); + } + + PSSshConfigEntry entry = + new PSSshConfigEntry(CreateConfigDict()); + + StreamWriter configSW = new StreamWriter(ConfigFilePath, !Overwrite); + configSW.WriteLine(entry.ConfigString); + configSW.Close(); + + record.RecordType = ProgressRecordType.Completed; + UpdateProgressBar(record, "Successfully wrote config file", 100); + } + + #region Private Methods + + private Dictionary CreateConfigDict() + { + Dictionary Config = new Dictionary(); + if (ResourceGroupName != null && Name != null) { Config.Add("Host", $"{ResourceGroupName}-{Name}"); } + else { Config.Add("Host", Ip); } + + if (IsArc()) + { + Config.Add("HostName", Name); + Config.Add("ProxyCommand", $"\"{proxyPath}\" -r \"{RelayInfoPath}\""); + if (Port != null) + Config["ProxyCommand"] = Config["ProxyCommand"] + $" -p {Port}"; + } + else + { + Config.Add("Port", Port); + if (Ip != null) { Config.Add("HostName", Ip); } + else { Config.Add("HostName", "*"); } + } + + Config.Add("User", LocalUser); + Config.Add("CertificateFile", CertificateFile); + Config.Add("IdentityFile", PrivateKeyFile); + Config.Add("ResourceType", ResourceType); + + if (deleteCert) { Config.Add("LoginType", "AAD"); } + else { Config.Add("LoginType", "LocalUser"); } + + return Config; + } + + private void CreateRelayInfoFile() + { + string relayInfoDir = GetKeysDestinationFolder(); + Directory.CreateDirectory(relayInfoDir); + + string relayInfoFilename = ResourceGroupName + "-" + Name + "-relay_info"; + RelayInfoPath = Path.Combine(relayInfoDir, relayInfoFilename); + DeleteFile(RelayInfoPath); + StreamWriter relaySW = new StreamWriter(RelayInfoPath); + relaySW.WriteLine(relayInfo); + relaySW.Close(); + + // This is not exactly a warning. But I couldn't make WriteInformation or WriteObject work. + WriteWarning($"Generated relay information file {RelayInfoPath} is valid until {relayInfoExpiration} in local time."); + } + + private string GetKeysDestinationFolder() + { + if (KeysDestinationFolder == null) + { + string configFolder = Path.GetDirectoryName(ConfigFilePath); + string keysFolderName = Ip; + if (ResourceGroupName != null && Name != null) + { + keysFolderName = ResourceGroupName + "-" + Name; + } + + if (keysFolderName.Equals("*")) + { + // If the user provides -Ip *, that would not be a valid name for Windows. Treat it as a special case. + keysFolderName = "all_ips"; + } + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + //Make sure that the folder name doesn't have illegal characters + string regexString = "[" + Regex.Escape(new string(Path.GetInvalidFileNameChars())) + "]"; + Regex containsInvalidCharacter = new Regex(regexString); + if (containsInvalidCharacter.IsMatch(keysFolderName)) + { + throw new AzPSInvalidOperationException("Unable to create default keys destination folder. Resource contain invalid characters. Please provide -KeysDestinationFolder."); + } + } + + return Path.Combine(configFolder, "az_ssh_config", keysFolderName); + } + return KeysDestinationFolder; + } + + #endregion + } +} diff --git a/src/Ssh/Ssh/StartupScripts/sample.ps1 b/src/Ssh/Ssh/StartupScripts/sample.ps1 new file mode 100644 index 000000000000..50ac77403ab6 --- /dev/null +++ b/src/Ssh/Ssh/StartupScripts/sample.ps1 @@ -0,0 +1 @@ +#Placeholder for future scripts: Please delete this file and uncomment Always within the block in the .csproj file. \ No newline at end of file diff --git a/src/Ssh/documentation/current-breaking-changes.md b/src/Ssh/documentation/current-breaking-changes.md new file mode 100644 index 000000000000..d38f925e6892 --- /dev/null +++ b/src/Ssh/documentation/current-breaking-changes.md @@ -0,0 +1,39 @@ + \ No newline at end of file diff --git a/src/Ssh/documentation/upcoming-breaking-changes.md b/src/Ssh/documentation/upcoming-breaking-changes.md new file mode 100644 index 000000000000..0e389879299c --- /dev/null +++ b/src/Ssh/documentation/upcoming-breaking-changes.md @@ -0,0 +1,26 @@ + \ No newline at end of file