github.com/niedbalski/juju@v0.0.0-20190215020005-8ff100488e47/environs/manual/winrmprovisioner/winrmprovisioner.go (about)

     1  // Copyright 2016 Canonical Ltd.
     2  // Copyright 2016 Cloudbase Solutions SRL
     3  // Licensed under the AGPLv3, see LICENCE file for details.
     4  
     5  package winrmprovisioner
     6  
     7  import (
     8  	"bytes"
     9  	"encoding/base64"
    10  	"fmt"
    11  	"html/template"
    12  	"io"
    13  	"strconv"
    14  	"strings"
    15  	"time"
    16  
    17  	"github.com/juju/errors"
    18  	"github.com/juju/os/series"
    19  	"github.com/juju/utils"
    20  	"github.com/juju/utils/arch"
    21  	"github.com/juju/utils/shell"
    22  	"github.com/juju/utils/winrm"
    23  
    24  	"github.com/juju/juju/apiserver/params"
    25  	"github.com/juju/juju/cloudconfig"
    26  	"github.com/juju/juju/cloudconfig/cloudinit"
    27  	"github.com/juju/juju/cloudconfig/instancecfg"
    28  	"github.com/juju/juju/core/instance"
    29  	"github.com/juju/juju/environs/manual"
    30  	"github.com/juju/juju/state/multiwatcher"
    31  )
    32  
    33  // detectJujudProcess powershell script to determine
    34  // if the jujud service is up and running in the machine
    35  // if it's up the script will output "Yes" if it's down
    36  // it will output "No"
    37  const detectJujudProcess = `
    38  	$jujuSvcs = Get-Service jujud-machine-*
    39  	if($jujuSvcs -and $jujuSvcs[0].Status -eq "running"){
    40  		return "Yes"
    41  	}
    42  	return "No"
    43  `
    44  
    45  // detectHardware is a powershell script that determines the following:
    46  //  - the processor architecture
    47  //		will try to determine the size of a int ptr, we know that any ptr on a x64 is
    48  //		always 8 bytes and on x32 4 bytes always
    49  //	- get the amount of ram the machine has
    50  //		Use a WMI call to fetch the amount of RAM on the system. See:
    51  //		https://msdn.microsoft.com/en-us/library/aa394347%28v=vs.85%29.aspx?f=255&MSPPError=-2147217396
    52  //		for more details
    53  //  - get the operating system name
    54  //      compare the values we find in the registry with the version information
    55  //		juju knows about. Once we find a match, we return the series
    56  //  - get number of cores that the machine has
    57  //		the process is using the Wmi windows Api to interrogate the os for
    58  //		the physical number of cores that the machine has.
    59  //
    60  const detectHardware = `
    61  function Get-Arch {
    62  	$arch = (Get-ItemProperty "HKLM:\system\CurrentControlSet\Control\Session Manager\Environment").PROCESSOR_ARCHITECTURE
    63  	return $arch.toString().ToLower()
    64  }
    65  
    66  function Get-Ram {
    67     return (gcim win32_physicalmemory).Capacity
    68  }
    69  
    70  function Get-OSName {
    71  	$version = @{}
    72  	{{ range $key, $value := . }}
    73  $version.Add("{{$key}}", "{{$value}}")
    74  	{{ end }}
    75  
    76  	$v = $(Get-ItemProperty -Path "HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion" -Name ProductName).ProductName
    77  	$name = $v.Trim()
    78  
    79  	# detection for nano server
    80  	$k = $(Get-ItemProperty -Path "HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Server\ServerLevels")
    81  	$nano = $k.NanoServer
    82  	if($nano -eq 1) {
    83  		return $version["Windows Server 2016"].Trim()
    84  	}
    85  
    86  	foreach ($h in $version.keys) {
    87  		$flag = $name.StartsWith($h)
    88  
    89  		if ($flag) {
    90  			return $version[$h].Trim()
    91  		}
    92  	}
    93  }
    94  function Get-NCores {
    95  	# NumberOfProcessors will return the physical processor, but not the core count of each CPU. So if you have 2 quad core CPUs, NumberOfProcessors will return 2, not 8
    96      # Use NumberOfLogicalProcessors here (also takes into account hyperthreading).
    97      # see https://msdn.microsoft.com/en-us/library/aa394102%28v=vs.85%29.aspx?f=255&MSPPError=-2147217396 for more information
    98  	Write-Host -NoNewline (gcim win32_computersystem).NumberOfLogicalProcessors
    99  }
   100  Get-Arch
   101  Get-Ram
   102  Get-OSName
   103  Get-NCores
   104  `
   105  
   106  // newDetectHardwareScript will parse the detectHardware script and add
   107  // into the powershell hastable the key,val of the map returned from the
   108  // WindowsVersions func from the series pkg.
   109  // it will return the script wrapped into a safe powershell base64
   110  func newDetectHardwareScript() (string, error) {
   111  	tmpl := template.Must(template.New("hc").Parse(detectHardware))
   112  	var in bytes.Buffer
   113  	seriesMap := series.WindowsVersions()
   114  	if err := tmpl.Execute(&in, seriesMap); err != nil {
   115  		return "", err
   116  	}
   117  	return shell.NewPSEncodedCommand(in.String())
   118  }
   119  
   120  // InitAdministratorUser will initially attempt to login as
   121  // the Administrator user using the secure client
   122  // only if this is false then this will make a new attempt with the unsecure http client.
   123  func InitAdministratorUser(args *manual.ProvisionMachineArgs) error {
   124  	logger.Infof("Trying https client as user %s on %s", args.Host, args.User)
   125  	err := args.WinRM.Client.Ping()
   126  	if err == nil {
   127  		logger.Infof("Https connection is enabled on the host %s with user %s", args.Host, args.User)
   128  		return nil
   129  	}
   130  
   131  	logger.Debugf("Https client authentication is not enabled on the host %s with user %s", args.Host, args.User)
   132  	if args.WinRM.Client, err = winrm.NewClient(winrm.ClientConfig{
   133  		User:     args.User,
   134  		Host:     args.Host,
   135  		Timeout:  25 * time.Second,
   136  		Password: winrm.TTYGetPasswd,
   137  		Secure:   false,
   138  	}); err != nil {
   139  		return errors.Annotatef(err, "cannot create a new http winrm client ")
   140  	}
   141  
   142  	logger.Infof("Trying http client as user %s on %s", args.Host, args.User)
   143  	if err = args.WinRM.Client.Ping(); err != nil {
   144  		logger.Debugf("WinRM unsecure listener is not enabled on %s", args.Host)
   145  		return errors.Annotatef(err, "cannot provision, because all winrm default connections failed")
   146  	}
   147  
   148  	defClient := args.WinRM.Client
   149  	logger.Infof("Trying to enable https client certificate authentication")
   150  	if args.WinRM.Client, err = enableCertAuth(args); err != nil {
   151  		logger.Infof("Cannot enable client auth cert authentication for winrm")
   152  		logger.Infof("Reverting back to usecure client interaction")
   153  		args.WinRM.Client = defClient
   154  		return nil
   155  	}
   156  
   157  	logger.Infof("Client certs are installed and setup on the %s with user %s", args.Host, args.User)
   158  	err = args.WinRM.Client.Ping()
   159  	if err == nil {
   160  		return nil
   161  	}
   162  
   163  	logger.Infof("Winrm https connection is broken, can't retrive a response")
   164  	logger.Infof("Reverting back to usecure client interactions")
   165  	args.WinRM.Client = defClient
   166  
   167  	return nil
   168  
   169  }
   170  
   171  // enableCertAuth enables https cert auth interactions
   172  // with the winrm listener and returns the client
   173  func enableCertAuth(args *manual.ProvisionMachineArgs) (manual.WinrmClientAPI, error) {
   174  	var stderr bytes.Buffer
   175  	pass := args.WinRM.Client.Password()
   176  
   177  	scripts, err := bindInitScripts(pass, args.WinRM.Keys)
   178  	if err != nil {
   179  		return nil, errors.Trace(err)
   180  	}
   181  
   182  	for _, script := range scripts {
   183  		err = args.WinRM.Client.Run(script, args.Stdout, &stderr)
   184  		if err != nil {
   185  			return nil, errors.Trace(err)
   186  		}
   187  		if stderr.Len() > 0 {
   188  			return nil, errors.Errorf(
   189  				"encountered error executing cert auth script: %s",
   190  				stderr.String(),
   191  			)
   192  		}
   193  	}
   194  
   195  	cfg := winrm.ClientConfig{
   196  		User:    args.User,
   197  		Host:    args.Host,
   198  		Key:     args.WinRM.Keys.ClientKey(),
   199  		Cert:    args.WinRM.Keys.ClientCert(),
   200  		Timeout: 25 * time.Second,
   201  		Secure:  true,
   202  	}
   203  
   204  	caCert := args.WinRM.Keys.CACert()
   205  	if caCert == nil {
   206  		logger.Infof("Skipping winrm CA validation")
   207  		cfg.Insecure = true
   208  	} else {
   209  		cfg.CACert = caCert
   210  	}
   211  
   212  	return winrm.NewClient(cfg)
   213  }
   214  
   215  // bindInitScripts creates a series of scripts in a standard
   216  // (utf-16-le, base64)format for passing to the winrm conn to be executed remotely
   217  // we are doing this instead of one big script because winrm supports
   218  // just 8192 length commands. We know we have an amount of prefixed scripts
   219  // that we want to bind for the init process so create an array of scripts
   220  func bindInitScripts(pass string, keys *winrm.X509) ([]string, error) {
   221  	var (
   222  		err error
   223  	)
   224  
   225  	scripts := make([]string, 3, 3)
   226  
   227  	if len(pass) == 0 {
   228  		return scripts, fmt.Errorf("The password is empty, provide a valid password to enable https interactions")
   229  	}
   230  
   231  	scripts[0], err = shell.NewPSEncodedCommand(setFiles)
   232  	if err != nil {
   233  		return nil, err
   234  	}
   235  
   236  	scripts[1] = fmt.Sprintf(setFilesContent, string(keys.ClientCert()))
   237  
   238  	scripts[1], err = shell.NewPSEncodedCommand(scripts[1])
   239  	if err != nil {
   240  		return nil, err
   241  	}
   242  
   243  	scripts[2] = fmt.Sprintf(setConnWinrm, pass)
   244  	scripts[2], err = shell.NewPSEncodedCommand(scripts[2])
   245  	if err != nil {
   246  		return nil, err
   247  	}
   248  
   249  	return nil, nil
   250  }
   251  
   252  // setFiles powershell script that will manage and create the conf folder and files
   253  const setFiles = `
   254  $jujuHome = [io.path]::Combine($ENV:APPDATA, 'Juju')
   255  $x509Path = [io.path]::Combine($jujuHome, 'x509')
   256  $certPath = [io.path]::Combine($x509Path,'winrmcert.crt')
   257  if (-Not (Test-Path $jujuHome)) {
   258  	New-Item $jujuHome -Type directory
   259  }
   260  if (-Not (Test-Path $x509Path)) {
   261  	New-Item $x509Path -Type directory
   262  }
   263  if (-Not (Test-Path $certPath)) {
   264  	New-Item $certPath -Type file
   265  }
   266  `
   267  
   268  // setFilesContent powershell script that will write
   269  // x509 cert and key into the juju conf.
   270  const setFilesContent = `
   271  $cert=@"
   272  %s
   273  "@
   274  [io.file]::WriteAllText("$ENV:APPDATA\Juju\x509\winrmcert.crt", $cert)
   275  `
   276  
   277  // setConnWinrm powershell script that will create and write the client cert from the juju conf file into the windows
   278  // target enabling winrm secure client interactions with the machine
   279  const setConnWinrm = `
   280  winrm set winrm/config/winrs '@{MaxMemoryPerShellMB="1024"}'
   281  winrm set winrm/config/client/auth '@{Digest="false"}'
   282  winrm set winrm/config/service/auth '@{Certificate="true"}'
   283  Remove-Item -Path WSMan:\localhost\ClientCertificate\ClientCertificate_* -Recurse -force | Out-null
   284  $username = "Administrator"
   285  $password = "%s"
   286  $client_cert_path = [io.path]::Combine($env:APPDATA, 'Juju', 'x509', 'winrmcert.crt')
   287  $clientcert = New-Object System.Security.Cryptography.X509Certificates.X509Certificate2($client_cert_path)
   288  $castore = New-Object System.Security.Cryptography.X509Certificates.X509Store(
   289  	    [System.Security.Cryptography.X509Certificates.StoreName]::Root,
   290  		[System.Security.Cryptography.X509Certificates.StoreLocation]::LocalMachine)
   291  $castore.Open([System.Security.Cryptography.X509Certificates.OpenFlags]::ReadWrite)
   292  $castore.Add($clientcert)
   293  $subject = [string]::Join([CHAR][BYTE]32, "juju", "winrm", "client", "cert")
   294  $secure_password = ConvertTo-SecureString $password -AsPlainText -Force
   295  $cred = New-Object System.Management.Automation.PSCredential "$ENV:COMPUTERNAME\$username", $secure_password
   296  New-Item -Path WSMan:\localhost\ClientCertificate -Issuer $clientcert.Thumbprint -Subject $subject -Uri * -Credential $cred -Force
   297  `
   298  
   299  // gatherMachineParams collects all the information we know about the machine
   300  // we are about to provision. It will winrm into that machine as the provision user
   301  // The hostname supplied should not include a username.
   302  // If we can, we will reverse lookup the hostname by its IP address, and use
   303  // the DNS resolved name, rather than the name that was supplied
   304  func gatherMachineParams(hostname string, cli manual.WinrmClientAPI) (*params.AddMachineParams, error) {
   305  	// Generate a unique nonce for the machine.
   306  	uuid, err := utils.NewUUID()
   307  	if err != nil {
   308  		return nil, err
   309  	}
   310  
   311  	addr, err := manual.HostAddress(hostname)
   312  	if err != nil {
   313  		return nil, errors.Annotatef(err, "failed to compute public address for %q", hostname)
   314  	}
   315  
   316  	provisioned, err := checkProvisioned(hostname, cli)
   317  	if err != nil {
   318  		return nil, errors.Annotatef(err, "cannot decide if the machine is provisioned")
   319  	}
   320  	if provisioned {
   321  		return nil, manual.ErrProvisioned
   322  	}
   323  
   324  	hc, series, err := DetectSeriesAndHardwareCharacteristics(hostname, cli)
   325  	if err != nil {
   326  		err = fmt.Errorf("error detecting windows characteristics: %v", err)
   327  		return nil, err
   328  	}
   329  	// There will never be a corresponding "instance" that any provider
   330  	// knows about. This is fine, and works well with the provisioner
   331  	// task. The provisioner task will happily remove any and all dead
   332  	// machines from state, but will ignore the associated instance ID
   333  	// if it isn't one that the environment provider knows about.
   334  
   335  	instanceId := instance.Id(manual.ManualInstancePrefix + hostname)
   336  	nonce := fmt.Sprintf("%s:%s", instanceId, uuid)
   337  	machineParams := &params.AddMachineParams{
   338  		Series:                  series,
   339  		HardwareCharacteristics: hc,
   340  		InstanceId:              instanceId,
   341  		Nonce:                   nonce,
   342  		Addrs:                   params.FromNetworkAddresses(addr),
   343  		Jobs:                    []multiwatcher.MachineJob{multiwatcher.JobHostUnits},
   344  	}
   345  	return machineParams, nil
   346  }
   347  
   348  // checkProvisioned checks if the machine is already provisioned or not.
   349  // if it's already provisioned it will return true
   350  func checkProvisioned(host string, cli manual.WinrmClientAPI) (bool, error) {
   351  	logger.Tracef("Checking if %s windows machine is already provisioned", host)
   352  	var stdout, stderr bytes.Buffer
   353  
   354  	// run the command trough winrm
   355  	// this script detects if the jujud process, service is up and running
   356  	script, err := shell.NewPSEncodedCommand(detectJujudProcess)
   357  	if err != nil {
   358  		return false, errors.Trace(err)
   359  	}
   360  
   361  	// send the script to the windows machine
   362  	if err = cli.Run(script, &stdout, &stderr); err != nil {
   363  		return false, errors.Trace(err)
   364  	}
   365  
   366  	provisioned := strings.Contains(stdout.String(), "Yes")
   367  	if stderr.Len() != 0 {
   368  		err = errors.Annotate(err, strings.TrimSpace(stderr.String()))
   369  	}
   370  
   371  	// if the script said yes
   372  	if provisioned {
   373  		logger.Infof("%s is already provisioned", host)
   374  	} else {
   375  		logger.Infof("%s is not provisioned", host)
   376  	}
   377  
   378  	return provisioned, err
   379  }
   380  
   381  // DetectSeriesAndHardwareCharacteristics detects the windows OS
   382  // series and hardware characteristics of the remote machine
   383  // by connecting to the machine and executing a bash script.
   384  func DetectSeriesAndHardwareCharacteristics(host string, cli manual.WinrmClientAPI) (hc instance.HardwareCharacteristics, series string, err error) {
   385  	logger.Infof("Detecting series and characteristics on %s windows machine", host)
   386  	var stdout, stderr bytes.Buffer
   387  
   388  	script, err := newDetectHardwareScript()
   389  	if err != nil {
   390  		return hc, "", err
   391  	}
   392  
   393  	// send the script to the windows machine
   394  	if err = cli.Run(script, &stdout, &stderr); err != nil {
   395  		return hc, "", errors.Trace(err)
   396  	}
   397  
   398  	if stderr.Len() != 0 {
   399  		return hc, "", fmt.Errorf("%v (%v)", err, strings.TrimSpace(stderr.String()))
   400  	}
   401  
   402  	info, err := splitHardWareScript(stdout.String())
   403  	if err != nil {
   404  		return hc, "", errors.Trace(err)
   405  	}
   406  
   407  	series = strings.Replace(info[2], "\r", "", -1)
   408  
   409  	if err = initHC(&hc, info); err != nil {
   410  		return hc, "", errors.Trace(err)
   411  	}
   412  
   413  	return hc, series, nil
   414  }
   415  
   416  // initHC it will initialize the hardware characterisrics struct with the
   417  // parsed and checked info slice string
   418  // info description :
   419  //  - info[0] the arch of the machine
   420  //  - info[1] the amount of memory that the machine has
   421  //  - info[2] the series of the machine
   422  //  - info[3] the number of cores that the machine has
   423  // It returns nil if it parsed successfully.
   424  func initHC(hc *instance.HardwareCharacteristics, info []string) error {
   425  	// add arch
   426  	arch := arch.NormaliseArch(info[0])
   427  	hc.Arch = &arch
   428  
   429  	// parse the mem number
   430  	mem, err := strconv.ParseUint(info[1], 10, 64)
   431  	if err != nil {
   432  		return errors.Annotatef(err, "Can't parse mem number of the windows machine")
   433  	}
   434  	hc.Mem = new(uint64)
   435  	*hc.Mem = mem
   436  
   437  	// parse the core number
   438  	cores, err := strconv.ParseUint(info[3], 10, 64)
   439  	if err != nil {
   440  		return errors.Annotatef(err, "Can't parse cores number of the windows machine")
   441  	}
   442  
   443  	hc.CpuCores = new(uint64)
   444  	*hc.CpuCores = cores
   445  	return nil
   446  }
   447  
   448  // splitHardwareScript will split the result from the detectHardware powershell script
   449  // to extract the information in a specific order.
   450  // this will return a slice of string that will be used in conjunctions with the above function
   451  func splitHardWareScript(script string) ([]string, error) {
   452  	scr := strings.Split(script, "\n")
   453  	n := len(scr)
   454  	if n < 3 {
   455  		return nil, fmt.Errorf("No hardware fields on running the powershell deteciton script, %s", script)
   456  	}
   457  	for i := 0; i < n; i++ {
   458  		scr[i] = strings.TrimSpace(scr[i])
   459  	}
   460  	return scr, nil
   461  }
   462  
   463  // RunProvisionScript exported for testing purposes
   464  var RunProvisionScript = runProvisionScript
   465  
   466  // runProvisionScript runs the script generated by the provisioner
   467  // the script can be big and the underlying protocol dosen't support long messages
   468  // we need to send it into little chunks saving first into a file and then execute it.
   469  func runProvisionScript(script string, cli manual.WinrmClientAPI, stdin, stderr io.Writer) (err error) {
   470  	script64 := base64.StdEncoding.EncodeToString([]byte(script))
   471  	input := bytes.NewBufferString(script64) // make new buffer out of script
   472  	// we must make sure to buffer the entire script
   473  	// in a sequential write fashion first into a script
   474  	// decouple the provisioning script into little 1024 byte chunks
   475  	// we are doing this in order to append into a .ps1 file.
   476  	var buf [1024]byte
   477  
   478  	// if the file dosen't exist ,create it
   479  	// if the file exists just clear/reset it
   480  	script, err = shell.NewPSEncodedCommand(initChunk)
   481  	if err != nil {
   482  		return err
   483  	}
   484  	if err = cli.Run(script, stdin, stderr); err != nil {
   485  		return errors.Trace(err)
   486  	}
   487  
   488  	// sequential read.
   489  	for input.Len() != 0 {
   490  		n, err := input.Read(buf[:])
   491  		if err != nil && err != io.EOF {
   492  			return errors.Trace(err)
   493  		}
   494  		script, err = shell.NewPSEncodedCommand(
   495  			fmt.Sprintf(saveChunk, string(buf[:n])),
   496  		)
   497  		if err != nil {
   498  			return errors.Trace(err)
   499  		}
   500  		if err = cli.Run(script, stdin, stderr); err != nil {
   501  			return errors.Trace(err)
   502  		}
   503  	}
   504  
   505  	// after the sendAndSave script is successfully done
   506  	// we must execute the newly writed script
   507  	script, err = shell.NewPSEncodedCommand(runCmdProv)
   508  	if err != nil {
   509  		return err
   510  	}
   511  	logger.Debugf("Running the provisioningScript")
   512  	var outerr bytes.Buffer
   513  	if err = cli.Run(script, stdin, &outerr); err != nil {
   514  		return errors.Trace(err)
   515  	}
   516  
   517  	if outerr.Len() != 0 {
   518  		return fmt.Errorf("%v ", strings.TrimSpace(outerr.String()))
   519  	}
   520  
   521  	return err
   522  }
   523  
   524  // initChunk creates or clears the file that the userdata will be appendend.
   525  const initChunk = `
   526  $provisionPath= [io.path]::Combine($ENV:APPDATA, 'Juju', 'provision.ps1')
   527  if (-Not (Test-Path $provisionPath)) {
   528  	New-Item $provisionPath -Type file
   529  } else {
   530  	Clear-Content $provisionPath
   531  }
   532  
   533  `
   534  
   535  // saveChunk powershell script that will append into the userdata file created after the
   536  // initChunk executed.
   537  // this will be called multiple times in a sequential order.
   538  const saveChunk = `
   539  $chunk= @"
   540  %s
   541  "@
   542  $provisionPath= [io.path]::Combine($ENV:APPDATA, 'Juju', 'provision.ps1')
   543  $stream = New-Object System.IO.StreamWriter -ArgumentList ([IO.File]::Open($provisionPath, "Append"))
   544  $stream.Write($chunk)
   545  $stream.close()
   546  `
   547  
   548  // runCmdProv powrshell script that decodes and executes the newly created userdata file
   549  // after the process of writing the sequantial script is done above
   550  const runCmdProv = `
   551  $provisionPath= [io.path]::Combine($ENV:APPDATA, 'Juju', 'provision.ps1')
   552  $script = [IO.File]::ReadAllText($provisionPath)
   553  $x = [System.Text.Encoding]::ASCII.GetString([System.Convert]::FromBase64String($script))
   554  Set-Content C:\udata.ps1 $x
   555  powershell.exe -ExecutionPolicy RemoteSigned -NonInteractive -File C:\udata.ps1
   556  `
   557  
   558  // ProvisioningScript generates a powershell script that can be
   559  // executed on a remote host to carry out the cloud-init
   560  // configuration.
   561  func ProvisioningScript(icfg *instancecfg.InstanceConfig) (string, error) {
   562  	cloudcfg, err := cloudinit.New(icfg.Series)
   563  	if err != nil {
   564  		return "", errors.Annotate(err, "error creating new cloud config")
   565  	}
   566  
   567  	udata, err := cloudconfig.NewUserdataConfig(icfg, cloudcfg)
   568  	if err != nil {
   569  		return "", errors.Annotate(err, "error creating new userdata based on the cloud config")
   570  	}
   571  
   572  	if err := udata.Configure(); err != nil {
   573  		return "", errors.Annotate(err, "error adding extra configurations in the userdata")
   574  	}
   575  
   576  	return cloudcfg.RenderScript()
   577  }