/// <summary>
        /// Installs the <b>vsdbg</b> debugger on the Raspberry if it's not already installed.
        /// </summary>
        /// <returns><c>true</c> on success.</returns>
        public async Task <bool> InstallDebuggerAsync()
        {
            if (PiStatus.HasDebugger)
            {
                return(await Task.FromResult(true));
            }

            LogInfo($"Installing VSDBG to: [{PackageHelper.RemoteDebuggerFolder}]");

            return(await PackageHelper.ExecuteWithProgressAsync <bool>($"Installing [vsdbg] debugger...",
                                                                       async() =>
            {
                var installScript =
                    $@"
if ! curl -sSL https://aka.ms/getvsdbgsh | /bin/sh /dev/stdin -v latest -l {PackageHelper.RemoteDebuggerFolder} ; then
    exit 1
fi

exit 0
";
                try
                {
                    var response = SudoCommand(CommandBundle.FromScript(installScript));

                    if (response.ExitCode == 0)
                    {
                        // Indicate that debugger is now installed.

                        PiStatus.HasDebugger = true;
                        return await Task.FromResult(true);
                    }
                    else
                    {
                        LogError(response.AllText);
                        return await Task.FromResult(false);
                    }
                }
                catch (Exception e)
                {
                    LogException(e);
                    return await Task.FromResult(false);
                }
            }));
        }
        //---------------------------------------------------------------------
        // Static members

        /// <summary>
        /// Establishes a SSH connection to the remote Raspberry Pi whose
        /// connection information is passed.
        /// </summary>
        /// <param name="connectionInfo">The connection information.</param>
        /// <param name="usePassword">Optionally forces use of the password instead of the public key.</param>
        /// <param name="projectSettings">
        /// Optionally specifies the project settings.  This must be specified for connections that
        /// will be used for remote debugging but may be omitted for connections just used for setting
        /// things up like SSH keys, etc.
        /// </param>
        /// <returns>The connection.</returns>
        /// <exception cref="Exception">Thrown when the connection could not be established.</exception>
        public static async Task <Connection> ConnectAsync(ConnectionInfo connectionInfo, bool usePassword = false, ProjectSettings projectSettings = null)
        {
            Covenant.Requires <ArgumentNullException>(connectionInfo != null, nameof(connectionInfo));

            try
            {
                if (!NetHelper.TryParseIPv4Address(connectionInfo.Host, out var address))
                {
                    Log($"DNS lookup for: {connectionInfo.Host}");

                    address = (await Dns.GetHostAddressesAsync(connectionInfo.Host)).FirstOrDefault();
                }

                if (address == null)
                {
                    throw new ConnectionException(connectionInfo.Host, "DNS lookup failed.");
                }

                SshCredentials credentials;

                if (string.IsNullOrEmpty(connectionInfo.PrivateKeyPath) || usePassword)
                {
                    Log($"[{connectionInfo.Host}]: Auth via username/password");

                    credentials = SshCredentials.FromUserPassword(connectionInfo.User, connectionInfo.Password);
                }
                else
                {
                    Log($"[{connectionInfo.Host}]: Auth via SSH keys");

                    credentials = SshCredentials.FromPrivateKey(connectionInfo.User, File.ReadAllText(connectionInfo.PrivateKeyPath));
                }

                var connection = new Connection(connectionInfo.Host, address, connectionInfo, credentials, projectSettings);

                connection.Connect(TimeSpan.Zero);
                await connection.InitializeAsync();

                return(connection);
            }
            catch (SshProxyException e)
            {
                if (usePassword ||
                    e.InnerException == null ||
                    e.InnerException.GetType() != typeof(SshAuthenticationException))
                {
                    RaspberryDebugger.Log.Exception(e, $"[{connectionInfo.Host}]");
                    throw;
                }

                if (string.IsNullOrEmpty(connectionInfo.PrivateKeyPath) ||
                    string.IsNullOrEmpty(connectionInfo.Password))
                {
                    RaspberryDebugger.Log.Exception(e, $"[{connectionInfo.Host}]: The connection must have a password or SSH private key.");
                    throw;
                }

                RaspberryDebugger.Log.Warning($"[{connectionInfo.Host}]: SSH auth failed: Try using the password and reauthorizing the public key");

                // SSH private key authentication didn't work.  This commonly happens
                // after the user has reimaged the Raspberry.  It's likely that the
                // user has setup the same username/password, so we'll try logging in
                // with those and just configure the current public key to be accepted
                // on the Raspberry.

                try
                {
                    var connection = await ConnectAsync(connectionInfo, usePassword : true);

                    // Append the public key to the user's [authorized_keys] file if it's
                    // not already present.

                    RaspberryDebugger.Log.Info($"[{connectionInfo.Host}]: Reauthorizing the public key");

                    var homeFolder = LinuxPath.Combine("/", "home", connectionInfo.User);
                    var publicKey  = File.ReadAllText(connectionInfo.PublicKeyPath).Trim();
                    var keyScript  =
                        $@"
mkdir -p {homeFolder}/.ssh
touch {homeFolder}/.ssh/authorized_keys

if ! grep --quiet '{publicKey}' {homeFolder}/.ssh/authorized_keys ; then
    echo '{publicKey}' >> {homeFolder}/.ssh/authorized_keys
    exit $?
fi

exit 0
";
                    connection.ThrowOnError(connection.RunCommand(CommandBundle.FromScript(keyScript)));
                    return(connection);
                }
                catch (Exception e2)
                {
                    // We've done all we can.

                    RaspberryDebugger.Log.Exception(e2, $"[{connectionInfo.Host}]");
                    throw;
                }
            }
            catch (Exception e)
            {
                RaspberryDebugger.Log.Exception(e, $"[{connectionInfo.Host}]");
                throw;
            }
        }
        /// <summary>
        /// Installs the specified .NET Core SDK on the Raspberry if it's not already installed.
        /// </summary>
        /// <param name="sdkVersion">The SDK version.</param>
        /// <returns><c>true</c> on success.</returns>
        public async Task <bool> InstallSdkAsync(string sdkVersion)
        {
            Covenant.Requires <ArgumentNullException>(!string.IsNullOrEmpty(sdkVersion), nameof(sdkVersion));

            // $todo(jefflill):
            //
            // Note that we're going to install that standalone SDK for the SDK
            // version rather than the SDK that shipped with Visual Studio.  I'm
            // assuming that the Visual Studio SDKs might have extra stuff we don't
            // need and it's also possible that the Visual Studio SDK for the SDK
            // version may not have shipped yet.
            //
            // We may want to re-evaluate this in the future.

            if (PiStatus.InstalledSdks.Any(sdk => sdk.Version == sdkVersion))
            {
                return(await Task.FromResult(true));    // Already installed
            }

            LogInfo($".NET Core SDK [v{sdkVersion}] is not installed.");

            // Locate the standalone SDK for the request .NET version.

            var targetSdk = PackageHelper.SdkCatalog.Items.SingleOrDefault(item => item.IsStandalone && item.Version == sdkVersion && item.Architecture == SdkArchitecture.ARM32);

            if (targetSdk == null)
            {
                // Fall back to the Visual Studio SDK, if there is one.

                targetSdk = PackageHelper.SdkCatalog.Items.SingleOrDefault(item => item.Version == sdkVersion);
                LogInfo($"Cannot find standalone SDK for [{sdkVersion}] for falling back to [{targetSdk.Name}], version [v{targetSdk.Version}].");
            }

            if (targetSdk == null)
            {
                LogError($"RasberryDebug is unaware of .NET Core SDK [v{sdkVersion}].");
                LogError($"Try updating the RasberryDebug extension or report this issue at:");
                LogError($"https://github.com/nforgeio/RaspberryDebugger/issues");

                return(await Task.FromResult(false));
            }

            // Install the SDK.

            LogInfo($"Installing SDK v{targetSdk.Version}");

            return(await PackageHelper.ExecuteWithProgressAsync <bool>($"Download and install SDK v{targetSdk.Version} on Raspberry...",
                                                                       async() =>
            {
                var installScript =
                    $@"
export DOTNET_ROOT={PackageHelper.RemoteDotnetFolder}

# Ensure that the packages required by .NET Core are installed:
#
#       https://docs.microsoft.com/en-us/dotnet/core/install/linux-debian#dependencies

if ! apt-get update ; then
    exit 1
fi

if ! apt-get install -yq libc6 libgcc1 libgssapi-krb5-2 libicu-dev libssl1.1 libstdc++6 zlib1g libgdiplus ; then
    exit 1
fi

# Remove any existing SDK download.  This might be present if a
# previous installation attempt failed.

if ! rm -f /tmp/dotnet-sdk.tar.gz ; then
    exit 1
fi

# Download the SDK installation file to a temporary file.

if ! wget --quiet -O /tmp/dotnet-sdk.tar.gz {targetSdk.Link} ; then
    exit 1
fi

# Verify the SHA512.

orgDir=$cwd
cd /tmp

if ! echo '{targetSdk.SHA512}  dotnet-sdk.tar.gz' | sha512sum --check - ; then
    cd $orgDir
    exit 1
fi

cd $orgDir

# Make sure the installation directory exists.

if ! mkdir -p $DOTNET_ROOT ; then
    exit 1
fi

# Unpack the SDK to the installation directory.

if ! tar -zxf /tmp/dotnet-sdk.tar.gz -C $DOTNET_ROOT --no-same-owner ; then
    exit 1
fi

# Remove the temporary installation file.

if ! rm /tmp/dotnet-sdk.tar.gz ; then
    exit 1
fi

exit 0
";
                try
                {
                    var response = SudoCommand(CommandBundle.FromScript(installScript));

                    if (response.ExitCode == 0)
                    {
                        // Add the newly installed SDK to the list of installed SDKs.

                        PiStatus.InstalledSdks.Add(new Sdk(targetSdk.Name, targetSdk.Version));
                        return await Task.FromResult(true);
                    }
                    else
                    {
                        LogError(response.AllText);
                        return await Task.FromResult(false);
                    }
                }
                catch (Exception e)
                {
                    LogException(e);
                    return await Task.FromResult(false);
                }
            }));
        }
        /// <summary>
        /// Initializes the connection by retrieving status from the remote Raspberry and ensuring
        /// that any packages required for executing remote commands are installed.  This will also
        /// create and configure a SSH key pair on both the workstation and remote Raspberry if one
        /// doesn't already exist so that subsequent connections can use key based authentication.
        /// </summary>
        /// <returns>The tracking <see cref="Task"/>.</returns>
        private async Task InitializeAsync()
        {
            await PackageHelper.ExecuteWithProgressAsync(
                $"Connecting to [{Name}]...",
                async() =>
            {
                // Disabling this because it looks like SUDO passwork prompting is disabled
                // by default for Raspberry Pi OS.
#if DISABLED
                // This call ensures that SUDO password prompting is disabled and the
                // the required hidden folders exist in the user's home directory.

                DisableSudoPrompt(password);
#endif
                // We need to ensure that [unzip] is installed so that [LinuxSshProxy] command
                // bundles will work.

                Log($"[{Name}]: Checking for: [unzip]");

                var response = SudoCommand("which unzip");

                if (response.ExitCode != 0)
                {
                    Log($"[{Name}]: Installing: [unzip]");

                    ThrowOnError(SudoCommand("sudo apt-get update"));
                    ThrowOnError(SudoCommand("sudo apt-get install -yq unzip"));
                }

                // We're going to execute a script the gathers everything in a single operation for speed.

                Log($"[{Name}]: Retrieving status");

                var statusScript =
                    $@"
# This script will return the status information via STDOUT line-by-line
# in this order:
#
# Chip Architecture
# PATH environment variable
# Unzip Installed (""unzip"" or ""unzip-missing"")
# Debugger Installed (""debugger-installed"" or ""debugger-missing"")
# List of installed SDKs names (e.g. 3.1.108) separated by commas
# Raspberry Model like:     Raspberry Pi 4 Model B Rev 1.2
# Raspberry Revision like:  c03112
#
# This script also ensures that the [/lib/dotnet] directory exists, that
# it has reasonable permissions, and that the folder exists on the system
# PATH and that DOTNET_ROOT points to the folder.

# Set the SDK and debugger installation paths.

DOTNET_ROOT={PackageHelper.RemoteDotnetFolder}
DEBUGFOLDER={PackageHelper.RemoteDebuggerFolder}

# Get the chip architecture

uname -m

# Get the current PATH

echo $PATH

# Detect whether [unzip] is installed.

if which unzip &> /dev/nul ; then
    echo 'unzip'
else
    echo 'unzip-missing'
fi

# Detect whether the [vsdbg] debugger is installed.

if [ -d $DEBUGFOLDER ] ; then
    echo 'debugger-installed'
else
    echo 'debugger-missing'
fi

# List the SDK folders.  These folder names are the same as the
# corresponding SDK name.  We'll list the files on one line
# with the SDK names separated by commas.  We'll return a blank
# line if the SDK directory doesn't exist.

if [ -d $DOTNET_ROOT/sdk ] ; then
    ls -m $DOTNET_ROOT/sdk
else
    echo ''
fi

# Output the Raspberry board model.

cat /proc/cpuinfo | grep '^Model\s' | grep -o 'Raspberry.*$'

# Output the Raspberry board revision.

cat /proc/cpuinfo | grep 'Revision\s' | grep -o '[0-9a-fA-F]*$'

# Ensure that the [/lib/dotnet] folder exists, that it's on the
# PATH and that DOTNET_ROOT are defined.

mkdir -p /lib/dotnet
chown root:root /lib/dotnet
chmod 755 /lib/dotnet

# Set these for the current session:

export DOTNET_ROOT={PackageHelper.RemoteDotnetFolder}
export PATH=$PATH:$DOTNET_ROOT

# and for future sessions too:

if ! grep --quiet DOTNET_ROOT /etc/profile ; then

    echo """"                                >> /etc/profile
    echo ""#------------------------------"" >> /etc/profile
    echo ""# Raspberry Debugger:""           >> /etc/profile
    echo ""export DOTNET_ROOT=$DOTNET_ROOT"" >> /etc/profile
    echo ""export PATH=$PATH""               >> /etc/profile
    echo ""#------------------------------"" >> /etc/profile
fi
";
                Log($"[{Name}]: Fetching status");

                response = ThrowOnError(SudoCommand(CommandBundle.FromScript(statusScript)));

                using (var reader = new StringReader(response.OutputText))
                {
                    var architecture = await reader.ReadLineAsync();
                    var path         = await reader.ReadLineAsync();
                    var hasUnzip     = await reader.ReadLineAsync() == "unzip";
                    var hasDebugger  = await reader.ReadLineAsync() == "debugger-installed";
                    var sdkLine      = await reader.ReadLineAsync();
                    var model        = await reader.ReadLineAsync();
                    var revision     = await reader.ReadToEndAsync();

                    revision = revision.Trim();         // Remove any whitespace at the end.

                    Log($"[{Name}]: architecture: {architecture}");
                    Log($"[{Name}]: path:         {path}");
                    Log($"[{Name}]: unzip:        {hasUnzip}");
                    Log($"[{Name}]: debugger:     {hasDebugger}");
                    Log($"[{Name}]: sdks:         {sdkLine}");
                    Log($"[{Name}]: model:        {model}");
                    Log($"[{Name}]: revision:     {revision}");

                    // Convert the comma separated SDK names into a [PiSdk] list.

                    var sdks = new List <Sdk>();

                    foreach (var sdkName in sdkLine.Split(new char[] { ',' }, StringSplitOptions.RemoveEmptyEntries).Select(sdk => sdk.Trim()))
                    {
                        // $todo(jefflill): We're only supporting 32-bit SDKs at this time.

                        var sdkCatalogItem = PackageHelper.SdkCatalog.Items.SingleOrDefault(item => item.Name == sdkName && item.Architecture == SdkArchitecture.ARM32);

                        if (sdkCatalogItem != null)
                        {
                            sdks.Add(new Sdk(sdkName, sdkCatalogItem.Version));
                        }
                        else
                        {
                            LogWarning($".NET SDK [{sdkName}] is present on [{Name}] but is not known to the RaspberryDebugger extension.  Consider updating the extension.");
                        }
                    }

                    PiStatus = new Status(
                        architecture:  architecture,
                        path:          path,
                        hasUnzip:      hasUnzip,
                        hasDebugger:   hasDebugger,
                        installedSdks: sdks,
                        model:         model,
                        revision:      revision
                        );
                }
            });

            // Create and configure an SSH key for this connection if one doesn't already exist.

            if (string.IsNullOrEmpty(connectionInfo.PrivateKeyPath) || !File.Exists(connectionInfo.PrivateKeyPath))
            {
                await PackageHelper.ExecuteWithProgressAsync("Creating SSH keys...",
                                                             async() =>
                {
                    // Create a 2048-bit private key with no passphrase on the Raspberry
                    // and then download it to our keys folder.  The key file name will
                    // be the host name of the Raspberry.

                    LogInfo("Creating SSH keys");

                    var workstationUser    = Environment.GetEnvironmentVariable("USERNAME");
                    var workstationName    = Environment.GetEnvironmentVariable("COMPUTERNAME");
                    var keyName            = Guid.NewGuid().ToString("d");
                    var homeFolder         = LinuxPath.Combine("/", "home", connectionInfo.User);
                    var tempPrivateKeyPath = LinuxPath.Combine(homeFolder, keyName);
                    var tempPublicKeyPath  = LinuxPath.Combine(homeFolder, $"{keyName}.pub");

                    try
                    {
                        var createKeyScript =
                            $@"
# Create the key pair

if ! ssh-keygen -t rsa -b 2048 -P '' -C '{workstationUser}@{workstationName}' -f {tempPrivateKeyPath} -m pem ; then
    exit 1
fi

# Append the public key to the user's [authorized_keys] file to enable it.

mkdir -p {homeFolder}/.ssh
touch {homeFolder}/.ssh/authorized_keys
cat {tempPublicKeyPath} >> {homeFolder}/.ssh/authorized_keys

exit 0
";
                        ThrowOnError(RunCommand(CommandBundle.FromScript(createKeyScript)));

                        // Download the public and private keys, persist them to the workstation
                        // and then update the connection info.

                        var connections            = PackageHelper.ReadConnections();
                        var existingConnectionInfo = connections.SingleOrDefault(c => c.Name == connectionInfo.Name);
                        var publicKeyPath          = Path.Combine(PackageHelper.KeysFolder, $"{connectionInfo.Name}.pub");
                        var privateKeyPath         = Path.Combine(PackageHelper.KeysFolder, connectionInfo.Name);

                        File.WriteAllBytes(publicKeyPath, DownloadBytes(tempPublicKeyPath));
                        File.WriteAllBytes(privateKeyPath, DownloadBytes(tempPrivateKeyPath));

                        connectionInfo.PrivateKeyPath = privateKeyPath;
                        connectionInfo.PublicKeyPath  = publicKeyPath;

                        if (existingConnectionInfo != null)
                        {
                            existingConnectionInfo.PrivateKeyPath = privateKeyPath;
                            existingConnectionInfo.PublicKeyPath  = publicKeyPath;

                            PackageHelper.WriteConnections(connections, disableLogging: true);
                        }
                    }
                    finally
                    {
                        // Delete the temporary key files on the Raspberry.

                        var removeKeyScript =
                            $@"
rm -f {tempPrivateKeyPath}
rm -f {tempPublicKeyPath}
";
                        ThrowOnError(SudoCommand(CommandBundle.FromScript(removeKeyScript)));
                    }

                    await Task.CompletedTask;
                });
            }
        }
        /// <summary>
        /// This function is the callback used to execute the command when the menu item is clicked.
        /// See the constructor to see how the menu item is associated with this function using
        /// OleMenuCommandService service and MenuCommand class.
        /// </summary>
        /// <param name="sender">Event sender.</param>
        /// <param name="e">Event args.</param>
#pragma warning disable VSTHRD100
        private async void Execute(object sender, EventArgs e)
#pragma warning restore VSTHRD100
        {
            await ThreadHelper.JoinableTaskFactory.SwitchToMainThreadAsync();

            if (!await DebugHelper.EnsureOpenSshAsync())
            {
                return;
            }

            var project = DebugHelper.GetTargetProject(dte);

            if (project == null)
            {
                return;
            }

            var projectProperties = ProjectProperties.CopyFrom(dte.Solution, project);

            if (!await DebugHelper.PublishProjectWithUIAsync(dte, dte.Solution, project, projectProperties))
            {
                return;
            }

            var connectionInfo = DebugHelper.GetDebugConnectionInfo(projectProperties);

            if (connectionInfo == null)
            {
                return;
            }

            // Identify the most recent SDK installed on the workstation that has the same
            // major and minor version numbers as the project.  We'll ensure that the same
            // SDK is installed on the Raspberry (further below).

            var targetSdk = DebugHelper.GetTargetSdk(projectProperties);

            if (targetSdk == null)
            {
                return;
            }

            // Establish a Raspberry connection to handle some things before we start the debugger.

            var connection = await DebugHelper.InitializeConnectionAsync(connectionInfo, targetSdk, projectProperties, PackageHelper.GetProjectSettings(dte.Solution, project));

            if (connection == null)
            {
                return;
            }

            using (connection)
            {
                // Generate a temporary [launch.json] file and launch the debugger.

                using (var tempFile = await CreateLaunchSettingsAsync(connectionInfo, projectProperties))
                {
                    dte.ExecuteCommand("DebugAdapterHost.Launch", $"/LaunchJson:\"{tempFile.Path}\"");
                }

                // Launch the browser for ASPNET apps if requested.  Note that we're going to do this
                // on a background task to poll the Raspberry, waiting for the app to create the create
                // the LISTENING socket.

                if (projectProperties.IsAspNet && projectProperties.AspLaunchBrowser)
                {
                    var baseUri     = $"http://{connectionInfo.Host}:{projectProperties.AspPort}";
                    var launchReady = false;

                    await NeonHelper.WaitForAsync(
                        async() =>
                    {
                        if (dte.Mode != vsIDEMode.vsIDEModeDebug)
                        {
                            // The developer must have stopped debugging before the ASPNET
                            // application was able to begin servicing requests.

                            return(true);
                        }

                        try
                        {
                            var appListeningScript =
                                $@"
if lsof -i -P -n | grep --quiet 'TCP \*:{projectProperties.AspPort} (LISTEN)' ; then
    exit 0
else
    exit 1
fi
";
                            var response = connection.SudoCommand(CommandBundle.FromScript(appListeningScript));

                            if (response.ExitCode != 0)
                            {
                                return(false);
                            }

                            // Wait just a bit longer to give the application a chance to
                            // perform any additional initialization.

                            await Task.Delay(TimeSpan.FromSeconds(1));

                            launchReady = true;
                            return(true);
                        }
                        catch
                        {
                            return(false);
                        }
                    },
                        timeout : TimeSpan.FromSeconds(30),
                        pollInterval : TimeSpan.FromSeconds(0.5));

                    if (launchReady)
                    {
                        NeonHelper.OpenBrowser($"{baseUri}{projectProperties.AspRelativeBrowserUri}");
                    }
                }
            }
        }
示例#6
0
        /// <inheritdoc/>
        public override void Run(CommandLine commandLine)
        {
            if (commandLine.Arguments.Count() == 0)
            {
                Help();
                Program.Exit(0);
            }

            var hyperv    = commandLine.HasOption("--hyperv");
            var xenserver = commandLine.HasOption("--xenserver");

            if (!hyperv && !xenserver)
            {
                Console.Error.WriteLine("**** ERROR: One of [--hyperv] or [--xenserver] must be specified.");
                Program.Exit(1);
            }
            else if (hyperv && xenserver)
            {
                Console.Error.WriteLine("**** ERROR: Only one of [--hyperv] or [--xenserver] can be specified.");
                Program.Exit(1);
            }

            var address = commandLine.Arguments.ElementAtOrDefault(0);

            if (string.IsNullOrEmpty(address))
            {
                Console.Error.WriteLine("**** ERROR: ADDRESS argument is required.");
                Program.Exit(1);
            }

            if (!IPAddress.TryParse(address, out var ipAddress))
            {
                Console.Error.WriteLine($"**** ERROR: [{address}] is not a valid IP address.");
                Program.Exit(1);
            }

            Program.MachineUsername = Program.CommandLine.GetOption("--machine-username", "sysadmin");
            Program.MachinePassword = Program.CommandLine.GetOption("--machine-password", "sysadmin0000");

            Covenant.Assert(Program.MachineUsername == KubeConst.SysAdminUser);

            Console.WriteLine();
            Console.WriteLine("** Prepare VM Template ***");
            Console.WriteLine();

            using (var server = Program.CreateNodeProxy <string>("vm-template", address, ipAddress, appendToLog: false))
            {
                // Disable sudo password prompts.

                Console.WriteLine("Disable [sudo] password");
                server.DisableSudoPrompt(Program.MachinePassword);
            }

            using (var server = Program.CreateNodeProxy <string>("vm-template", address, ipAddress, appendToLog: false))
            {
                Console.WriteLine($"Connect as [{KubeConst.SysAdminUser}]");
                server.WaitForBoot();

                // Install required packages:

                Console.WriteLine("Install packages");
                server.SudoCommand("apt-get update", RunOptions.FaultOnError);
                server.SudoCommand("apt-get install -yq --allow-downgrades zip secure-delete", RunOptions.FaultOnError);

                // Disable SWAP by editing [/etc/fstab] to remove the [/swap.img] line:

                var sbFsTab = new StringBuilder();

                using (var reader = new StringReader(server.DownloadText("/etc/fstab")))
                {
                    foreach (var line in reader.Lines())
                    {
                        if (!line.Contains("/swap.img"))
                        {
                            sbFsTab.AppendLine(line);
                        }
                    }
                }

                Console.WriteLine("Disable SWAP");
                server.UploadText("/etc/fstab", sbFsTab, permissions: "644", owner: "root:root");

                // We need to relocate the [sysadmin] UID/GID to 1234 so we
                // can create the [container] user and group at 1000.  We'll
                // need to create a temporary user with root permissions to
                // delete and then recreate the [sysadmin] account.

                Console.WriteLine("Create [temp] user");

                var tempUserScript =
                    $@"#!/bin/bash

# Create the [temp] user.

useradd --uid 5000 --create-home --groups root temp
echo 'temp:{Program.MachinePassword}' | chpasswd
adduser temp sudo
chown temp:temp /home/temp
";
                server.SudoCommand(CommandBundle.FromScript(tempUserScript), RunOptions.FaultOnError);
            }

            // We need to reconnect with the new temporary account so
            // we can relocate the [sysadmin] user to its new UID.

            Program.MachineUsername = "******";

            using (var server = Program.CreateNodeProxy <string>("vm-template", address, ipAddress, appendToLog: false))
            {
                Console.WriteLine($"Connecting as [temp]");
                server.WaitForBoot(createHomeFolders: true);

                var sysadminUserScript =
                    $@"#!/bin/bash

# Update all file references from the old to new [sysadmin]
# user and group IDs:

find / -group 1000 -exec chgrp -h {KubeConst.SysAdminGroup} {{}} \;
find / -user 1000 -exec chown -h {KubeConst.SysAdminUser} {{}} \;

# Relocate the user ID:

groupmod --gid {KubeConst.SysAdminGID} {KubeConst.SysAdminGroup}
usermod --uid {KubeConst.SysAdminUID} --gid {KubeConst.SysAdminGID} --groups root,sysadmin,sudo {KubeConst.SysAdminUser}
";
                Console.WriteLine("Relocate [sysadmin] user");
                server.SudoCommand(CommandBundle.FromScript(sysadminUserScript), RunOptions.FaultOnError);
            }

            // We need to reconnect again with [sysadmin] so we can remove
            // the [temp] user and create the [container] user and then
            // wrap things up.

            Program.MachineUsername = KubeConst.SysAdminUser;

            using (var server = Program.CreateNodeProxy <string>("vm-template", address, ipAddress, appendToLog: false))
            {
                Console.WriteLine($"Connect as [{KubeConst.SysAdminUser}]");
                server.WaitForBoot();

                // Ensure that the owner and group for files in the [sysadmin]
                // home folder are correct.

                Console.WriteLine("Set [sysadmin] home folder owner");
                server.SudoCommand($"chown -R {KubeConst.SysAdminUser}:{KubeConst.SysAdminGroup} .*", RunOptions.FaultOnError);

                // Remove the [temp] user.

                Console.WriteLine("Remove [temp] user");
                server.SudoCommand($"userdel temp", RunOptions.FaultOnError);
                server.SudoCommand($"rm -rf /home/temp", RunOptions.FaultOnError);

                // Create the [container] user with no home directory.  This
                // means that the [container] user will have no chance of
                // logging into the machine.

                Console.WriteLine("Create [container] user", RunOptions.FaultOnError);
                server.SudoCommand($"useradd --uid {KubeConst.ContainerUID} --no-create-home {KubeConst.ContainerUser}", RunOptions.FaultOnError);

                // Configure the Linux guest integration services.

                var guestServicesScript =
                    @"#!/bin/bash
cat <<EOF >> /etc/initramfs-tools/modules
hv_vmbus
hv_storvsc
hv_blkvsc
hv_netvsc
EOF

apt-get install -yq --allow-downgrades linux-virtual linux-cloud-tools-virtual linux-tools-virtual
update-initramfs -u
";
                Console.WriteLine("Install guest integration services");
                server.SudoCommand(CommandBundle.FromScript(guestServicesScript), RunOptions.FaultOnError);
                if (hyperv)
                {
                    // Clean cached packages, DHCP leases, and then zero the disk so
                    // the image will compress better.

                    var cleanScript =
                        @"#!/bin/bash
apt-get clean
rm -rf /var/lib/dhcp/*
sfill -fllz /
";
                    Console.WriteLine("Clean up");
                    server.SudoCommand(CommandBundle.FromScript(cleanScript), RunOptions.FaultOnError);

                    // Shut the the VM down so the user can compress and upload
                    // the disk image.

                    Console.WriteLine("Shut down");
                    server.Shutdown();

                    Console.WriteLine();
                    Console.WriteLine("*** Node template is ready ***");
                }
                else if (xenserver)
                {
                    // NOTE: We need to to install the XenCenter tools manually.

                    Console.WriteLine();
                    Console.WriteLine("*** IMPORTANT: You need to manually complete the remaining steps ***");
                }
            }

            Program.Exit(0);
        }