From df96e7420dd1c258c794d2181d911add3eea8c47 Mon Sep 17 00:00:00 2001 From: "Marc A. Paradise" Date: Wed, 20 Feb 2019 10:55:01 -0500 Subject: Bootstrap via chef_core Make bootstrap use train via chef_core/TargetHost This commit implements usage of TargetHost instead of knife_ssh. TargetHost is a platform-independent representation of a Train connection. It abstracts common operations (such as file upload/download, permissions, temp directories, command executation, etc) and connection error handling. Moving to TargetHost and train gives us the ability to execute commands on the bootstrap target; instead of running sh -c 'long-command-string-containing-secrets', we'll now upload the bootstrap script to a temporary directory on the bootstrap target and execute it there. Incorporating WinRM support directly (allowing core support for Windows bootstraps, without the knife-windows plugin) will follow. THis also improves unit test coverage of the bootstrap module, and clarifies functional versus behavior tests in that space Signed-off-by: Marc A. Paradise --- lib/chef/knife/bootstrap.rb | 727 +++++++++++++-------- lib/chef/knife/bootstrap/options.rb | 358 ++++++++++ .../templates/windows-chef-client-msi.erb | 271 ++++++++ lib/chef/knife/core/bootstrap_context.rb | 2 +- lib/chef/knife/core/ui.rb | 7 + lib/chef/knife/core/windows_bootstrap_context.rb | 412 ++++++++++++ 6 files changed, 1490 insertions(+), 287 deletions(-) create mode 100644 lib/chef/knife/bootstrap/options.rb create mode 100644 lib/chef/knife/bootstrap/templates/windows-chef-client-msi.erb create mode 100644 lib/chef/knife/core/windows_bootstrap_context.rb (limited to 'lib/chef') diff --git a/lib/chef/knife/bootstrap.rb b/lib/chef/knife/bootstrap.rb index b9e09a15ba..aa5d1f502a 100644 --- a/lib/chef/knife/bootstrap.rb +++ b/lib/chef/knife/bootstrap.rb @@ -1,6 +1,6 @@ # # Author:: Adam Jacob () -# Copyright:: Copyright 2010-2016, Chef Software Inc. +# Copyright:: Copyright 2010-2019, Chef Software Inc. # License:: Apache License, Version 2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); @@ -22,229 +22,31 @@ require "erubis" require "chef/knife/bootstrap/chef_vault_handler" require "chef/knife/bootstrap/client_builder" require "chef/util/path_helper" +require "chef/knife/bootstrap/options" class Chef class Knife class Bootstrap < Knife include DataBagSecretOptions + # Command line flags and options for bootstrap - there's a large number of them + # so we'll keep this file a little smaller by splitting them out. + include Bootstrap::Options + + SUPPORTED_CONNECTION_PROTOCOLS = %w{ssh winrm} attr_accessor :client_builder attr_accessor :chef_vault_handler + attr_reader :target_host deps do - require "chef/knife/core/bootstrap_context" require "chef/json_compat" require "tempfile" - require "highline" - require "net/ssh" - require "net/ssh/multi" - require "chef/knife/ssh" - Chef::Knife::Ssh.load_deps - end - - banner "knife bootstrap [SSH_USER@]FQDN (options)" - - option :ssh_user, - short: "-x USERNAME", - long: "--ssh-user USERNAME", - description: "The ssh username", - default: "root" - - option :ssh_password, - short: "-P PASSWORD", - long: "--ssh-password PASSWORD", - description: "The ssh password" - - option :ssh_port, - short: "-p PORT", - long: "--ssh-port PORT", - description: "The ssh port", - proc: Proc.new { |key| Chef::Config[:knife][:ssh_port] = key } - - option :ssh_gateway, - short: "-G GATEWAY", - long: "--ssh-gateway GATEWAY", - description: "The ssh gateway", - proc: Proc.new { |key| Chef::Config[:knife][:ssh_gateway] = key } - - option :ssh_gateway_identity, - long: "--ssh-gateway-identity SSH_GATEWAY_IDENTITY", - description: "The SSH identity file used for gateway authentication", - proc: Proc.new { |key| Chef::Config[:knife][:ssh_gateway_identity] = key } - - option :forward_agent, - short: "-A", - long: "--forward-agent", - description: "Enable SSH agent forwarding", - boolean: true - - option :ssh_identity_file, - short: "-i IDENTITY_FILE", - long: "--ssh-identity-file IDENTITY_FILE", - description: "The SSH identity file used for authentication" - - option :chef_node_name, - short: "-N NAME", - long: "--node-name NAME", - description: "The Chef node name for your new node" - - option :prerelease, - long: "--prerelease", - description: "Install the pre-release chef gems" - - option :bootstrap_version, - long: "--bootstrap-version VERSION", - description: "The version of Chef to install", - proc: lambda { |v| Chef::Config[:knife][:bootstrap_version] = v } - - option :bootstrap_proxy, - long: "--bootstrap-proxy PROXY_URL", - description: "The proxy server for the node being bootstrapped", - proc: Proc.new { |p| Chef::Config[:knife][:bootstrap_proxy] = p } - - option :bootstrap_proxy_user, - long: "--bootstrap-proxy-user PROXY_USER", - description: "The proxy authentication username for the node being bootstrapped" - - option :bootstrap_proxy_pass, - long: "--bootstrap-proxy-pass PROXY_PASS", - description: "The proxy authentication password for the node being bootstrapped" - - option :bootstrap_no_proxy, - long: "--bootstrap-no-proxy [NO_PROXY_URL|NO_PROXY_IP]", - description: "Do not proxy locations for the node being bootstrapped; this option is used internally by Opscode", - proc: Proc.new { |np| Chef::Config[:knife][:bootstrap_no_proxy] = np } - - option :bootstrap_template, - short: "-t TEMPLATE", - long: "--bootstrap-template TEMPLATE", - description: "Bootstrap Chef using a built-in or custom template. Set to the full path of an erb template or use one of the built-in templates." - - option :use_sudo, - long: "--sudo", - description: "Execute the bootstrap via sudo", - boolean: true - - option :preserve_home, - long: "--sudo-preserve-home", - description: "Preserve non-root user HOME environment variable with sudo", - boolean: true - - option :use_sudo_password, - long: "--use-sudo-password", - description: "Execute the bootstrap via sudo with password", - boolean: false - - option :run_list, - short: "-r RUN_LIST", - long: "--run-list RUN_LIST", - description: "Comma separated list of roles/recipes to apply", - proc: lambda { |o| o.split(/[\s,]+/) }, - default: [] - - option :policy_name, - long: "--policy-name POLICY_NAME", - description: "Policyfile name to use (--policy-group must also be given)", - default: nil - - option :policy_group, - long: "--policy-group POLICY_GROUP", - description: "Policy group name to use (--policy-name must also be given)", - default: nil - - option :tags, - long: "--tags TAGS", - description: "Comma separated list of tags to apply to the node", - proc: lambda { |o| o.split(/[\s,]+/) }, - default: [] - - option :first_boot_attributes, - short: "-j JSON_ATTRIBS", - long: "--json-attributes", - description: "A JSON string to be added to the first run of chef-client", - proc: lambda { |o| Chef::JSONCompat.parse(o) }, - default: nil - - option :first_boot_attributes_from_file, - long: "--json-attribute-file FILE", - description: "A JSON file to be used to the first run of chef-client", - proc: lambda { |o| Chef::JSONCompat.parse(File.read(o)) }, - default: nil - - option :host_key_verify, - long: "--[no-]host-key-verify", - description: "Verify host key, enabled by default.", - boolean: true, - default: true - - option :hint, - long: "--hint HINT_NAME[=HINT_FILE]", - description: "Specify Ohai Hint to be set on the bootstrap target. Use multiple --hint options to specify multiple hints.", - proc: Proc.new { |h| - Chef::Config[:knife][:hints] ||= Hash.new - name, path = h.split("=") - Chef::Config[:knife][:hints][name] = path ? Chef::JSONCompat.parse(::File.read(path)) : Hash.new - } - - option :bootstrap_url, - long: "--bootstrap-url URL", - description: "URL to a custom installation script", - proc: Proc.new { |u| Chef::Config[:knife][:bootstrap_url] = u } - - option :bootstrap_install_command, - long: "--bootstrap-install-command COMMANDS", - description: "Custom command to install chef-client", - proc: Proc.new { |ic| Chef::Config[:knife][:bootstrap_install_command] = ic } - - option :bootstrap_preinstall_command, - long: "--bootstrap-preinstall-command COMMANDS", - description: "Custom commands to run before installing chef-client", - proc: Proc.new { |preic| Chef::Config[:knife][:bootstrap_preinstall_command] = preic } - - option :bootstrap_wget_options, - long: "--bootstrap-wget-options OPTIONS", - description: "Add options to wget when installing chef-client", - proc: Proc.new { |wo| Chef::Config[:knife][:bootstrap_wget_options] = wo } - - option :bootstrap_curl_options, - long: "--bootstrap-curl-options OPTIONS", - description: "Add options to curl when install chef-client", - proc: Proc.new { |co| Chef::Config[:knife][:bootstrap_curl_options] = co } - - option :node_ssl_verify_mode, - long: "--node-ssl-verify-mode [peer|none]", - description: "Whether or not to verify the SSL cert for all HTTPS requests.", - proc: Proc.new { |v| - valid_values = %w{none peer} - unless valid_values.include?(v) - raise "Invalid value '#{v}' for --node-ssl-verify-mode. Valid values are: #{valid_values.join(", ")}" - end - v - } + require "chef_core/text" # i18n and standardized error structures + require "chef_core/target_host" + require "chef_core/target_resolver" + end - option :node_verify_api_cert, - long: "--[no-]node-verify-api-cert", - description: "Verify the SSL cert for HTTPS requests to the Chef server API.", - boolean: true - - option :bootstrap_vault_file, - long: "--bootstrap-vault-file VAULT_FILE", - description: "A JSON file with a list of vault(s) and item(s) to be updated" - - option :bootstrap_vault_json, - long: "--bootstrap-vault-json VAULT_JSON", - description: "A JSON string with the vault(s) and item(s) to be updated" - - option :bootstrap_vault_item, - long: "--bootstrap-vault-item VAULT_ITEM", - description: 'A single vault and item to update as "vault:item"', - proc: Proc.new { |i| - (vault, item) = i.split(/:/) - Chef::Config[:knife][:bootstrap_vault_item] ||= {} - Chef::Config[:knife][:bootstrap_vault_item][vault] ||= [] - Chef::Config[:knife][:bootstrap_vault_item][vault].push(item) - Chef::Config[:knife][:bootstrap_vault_item] - } + banner "knife bootstrap [PROTOCOL://][USER@]FQDN (options)" def initialize(argv = []) super @@ -259,12 +61,16 @@ class Chef ) end - # The default bootstrap template to use to bootstrap a server This is a public API hook - # which knife plugins use or inherit and override. + # The default bootstrap template to use to bootstrap a server. + # This is a public API hook which knife plugins use or inherit and override. # # @return [String] Default bootstrap template def default_bootstrap_template - "chef-full" + if target_host.base_os == :windows + "windows-chef-client-msi" + else + "chef-full" + end end def host_descriptor @@ -282,15 +88,10 @@ class Chef end end - def user_name - if host_descriptor - @user_name ||= host_descriptor.split("@").reverse[1] - end - end + # @return [String] The CLI specific bootstrap template or the default def bootstrap_template # Allow passing a bootstrap template or use the default - # @return [String] The CLI specific bootstrap template or the default config[:bootstrap_template] || default_bootstrap_template end @@ -330,13 +131,18 @@ class Chef @secret ||= encryption_secret_provided_ignore_encrypt_flag? ? read_secret : nil end + # Establish bootstrap context for template rendering. + # Requires target_host to be a live connection in order to determine + # the correct platform. def bootstrap_context - @bootstrap_context ||= Knife::Core::BootstrapContext.new( - config, - config[:run_list], - Chef::Config, - secret - ) + @bootstrap_context ||= + if target_host.base_os == :windows + require "chef/knife/core/windows_bootstrap_context" + Knife::Core::WindowsBootstrapContext.new(config, config[:run_list], Chef::Config, secret) + else + require "chef/knife/core/bootstrap_context" + Knife::Core::BootstrapContext.new(config, config[:run_list], Chef::Config, secret) + end end def first_boot_attributes @@ -351,48 +157,153 @@ class Chef end def run - if @config[:first_boot_attributes] && @config[:first_boot_attributes_from_file] - raise Chef::Exceptions::BootstrapCommandInputError - end - validate_name_args! - validate_options! + validate_protocol! + validate_first_boot_attributes! + validate_winrm_transport_opts! + validate_policy_options! + + winrm_warn_no_ssl_verification $stdout.sync = true + register_client + connect! + unless client_builder.client_path.nil? + bootstrap_context.client_pem = client_builder.client_path + end + content = render_template + bootstrap_path = upload_bootstrap(content) + perform_bootstrap(bootstrap_path) + ensure + target_host.del_file(bootstrap_path) if target_host && bootstrap_path + end + def register_client # chef-vault integration must use the new client-side hawtness, otherwise to use the # new client-side hawtness, just delete your validation key. + # 2019-04-01 TODO + # TODO - should this raise if config says to use vault because json/file/item exists + # but we still have a validation key? That means we can't use the new client hawtness, + # but we also don't tell the operator that their requested vault operations + # won't be performed if chef_vault_handler.doing_chef_vault? || - (Chef::Config[:validation_key] && !File.exist?(File.expand_path(Chef::Config[:validation_key]))) + (Chef::Config[:validation_key] && + !File.exist?(File.expand_path(Chef::Config[:validation_key]))) unless config[:chef_node_name] ui.error("You must pass a node name with -N when bootstrapping with user credentials") exit 1 end - client_builder.run - chef_vault_handler.run(client_builder.client) - - bootstrap_context.client_pem = client_builder.client_path else - ui.info("Doing old-style registration with the validation key at #{Chef::Config[:validation_key]}...") - ui.info("Delete your validation key in order to use your user credentials instead") - ui.info("") + ui.info <<~EOM + Doing old-style registration with the validation key at #{Chef::Config[:validation_key]}..." + Delete your validation key in order to use your user credentials instead + EOM + end + end - ui.info("Connecting to #{ui.color(server_name, :bold)}") - begin - knife_ssh.run - rescue Net::SSH::AuthenticationFailed - if config[:ssh_password] + def perform_bootstrap(remote_bootstrap_script_path) + ui.info("Bootstrapping #{ui.color(server_name, :bold)}") + cmd = bootstrap_command(remote_bootstrap_script_path) + r = target_host.run_command(cmd) do |data| + ui.msg("#{ui.color(" [#{target_host.hostname}]", :cyan)} #{data}") + end + if r.exit_status != 0 + ui.error("The following error occurred on #{server_name}:") + ui.error(r.stderr) + exit 1 + end + end + + def connect! + + ui.info("Connecting to #{ui.color(server_name, :bold)}") + opts = connection_opts.dup + do_connect(opts) + rescue => e + # Ugh. TODO: Train raises a Train::Transports::SSHFailed for a number of different errors. chef_core makes that + # a more general ConnectionFailed, with an error code based on the specific error text/reason provided from trainm. + # This means we have to look three layers into the exception to find out what actually happened instead of just + # looking at the exception type + # + # It doesn't help to provide our own error if it does't let the caller know what they need to identify the problem. + # Let's update chef_core to be a bit smarter about resolving the errors to an appropriate exception type + # (eg ChefCore::ConnectionFailed::AuthError or similar) that will work across protocols, instead of just a single + # ConnectionFailure type + # + + if e.cause && e.cause.cause && e.cause.cause.class == Net::SSH::AuthenticationFailed + if opts[:password] raise else - ui.info("Failed to authenticate #{knife_ssh.config[:ssh_user]} - trying password auth") - knife_ssh_with_password_auth.run + ui.warn("Failed to authenticate #{opts[:user]} to #{server_name} - trying password auth") + password = ui.ask("Enter password for #{opts[:user]}@#{server_name} - trying password auth") do |q| + q.echo = false + end + end + opts.merge! force_ssh_password_opts(password) + do_connect(opts) + else + raise + end + end + + def connection_protocol + return @connection_protocol if @connection_protocol + from_url = host_descriptor =~ /^(.*):\/\// ? $1 : nil + from_cli = config[:connection_protocol] + from_knife = Chef::Config[:knife][:connection_protocol] + @connection_protocol = from_url || from_cli || from_knife || "ssh" + end + + def do_connect(conn_options) + # Resolve the given host name to a TargetHost instance. We will limit + # the number of hosts to 1 (effectivly eliminating wildcard support) since + # we only support running bootstrap against one host at a time. + resolver = ChefCore::TargetResolver.new(host_descriptor, connection_protocol, + conn_options, max_expanded_targets: 1) + @target_host = resolver.targets.first + target_host.connect! + target_host + end + + # Fail if both first_boot_attributes and first_boot_attributes_from_file + # are set. + def validate_first_boot_attributes! + if @config[:first_boot_attributes] && @config[:first_boot_attributes_from_file] + raise Chef::Exceptions::BootstrapCommandInputError + end + true + end + + # Fail if using plaintext auth without ssl because + # this can expose keys in plaintext on the wire. + # TODO test for this method + # TODO check that the protoocol is valid. + def validate_winrm_transport_opts! + return true if connection_protocol != "winrm" + + + if (Chef::Config[:validation_key] && !File.exist?(File.expand_path(Chef::Config[:validation_key]))) + if (config_value(:winrm_auth_method) == "plaintext" && + config_value(:winrm_ssl) != true) + ui.error <<~EOM + Validatorless bootstrap over unsecure winrm channels could expose your + key to network sniffing. + + Please use a 'winrm_auth_method' other than 'plaintext', + or enable ssl on #{server_name} then use the --ssl flag + to connect. + EOM + + exit 1 end end + true end # fail if the server_name is nil @@ -400,9 +311,6 @@ class Chef if server_name.nil? ui.error("Must pass an FQDN or ip to bootstrap") exit 1 - elsif server_name == "windows" - # catches "knife bootstrap windows" when that command is not installed - ui.warn("'knife bootstrap windows' specified, but the knife-windows plugin is not installed. Please install 'knife-windows' if you are attempting to bootstrap a Windows node via WinRM.") end end @@ -413,7 +321,7 @@ class Chef # * Policyfile options are set and --run-list is set as well # # @return [TrueClass] If options are valid. - def validate_options! + def validate_policy_options! if incomplete_policyfile_options? ui.error("--policy-name and --policy-group must be specified together") exit 1 @@ -421,51 +329,299 @@ class Chef ui.error("Policyfile options and --run-list are exclusive") exit 1 end - true end - # setup a Chef::Knife::Ssh object using the passed config options + # Ensure a valid protocol is provided for target host connection # - # @return Chef::Knife::Ssh - def knife_ssh - ssh = Chef::Knife::Ssh.new - ssh.ui = ui - ssh.name_args = [ server_name, ssh_command ] - ssh.config[:ssh_user] = user_name || config[:ssh_user] - ssh.config[:ssh_password] = config[:ssh_password] - ssh.config[:ssh_port] = config[:ssh_port] - ssh.config[:ssh_gateway] = config[:ssh_gateway] - ssh.config[:ssh_gateway_identity] = config[:ssh_gateway_identity] - ssh.config[:forward_agent] = config[:forward_agent] - ssh.config[:ssh_identity_file] = config[:ssh_identity_file] - ssh.config[:manual] = true - ssh.config[:host_key_verify] = config[:host_key_verify] - ssh.config[:on_error] = true - ssh - end - - # prompt for a password then return a knife ssh object with that password set - # and with ssh_identity_file set to nil + # The method call will cause the program to exit(1) if: + # * Conflicting protocols are given via the target URI and the --protocol option + # * The protocol is not a supported protocol + # + # @return [TrueClass] If options are valid. + def validate_protocol! + from_cli = config[:connection_protocol] + if (from_cli && connection_protocol != from_cli) + # Hanging indent to align with the ERROR: prefix + ui.error <<~EOM + The URL '#{host_descriptor}' indicates protocol is '#{connection_protocol}' + while the --protocol flag specifies '#{from_cli}'. Please include + only one or the other. + EOM + exit 1 + end + + unless SUPPORTED_CONNECTION_PROTOCOLS.include?(connection_protocol) + ui.error <<~EOM + Unsupported protocol '#{connection_protocol}'. + + Supported protocols are: #{SUPPORTED_CONNECTION_PROTOCOLS.join(" ")} + EOM + exit 1 + end + true + end + + def winrm_warn_no_ssl_verification + return if connection_protocol != "winrm" + + # REVIEWER NOTE + # The original check from knife plugin did not include winrm_ssl_peer_fingerprint + # Reference: + # https://github.com/chef/knife-windows/blob/92d151298142be4a4750c5b54bb264f8d5b81b8a/lib/chef/knife/winrm_knife_base.rb#L271-L273 + # TODO Seems like we should also do a similar warning if ssh_verify_host == false + if config_value(:ca_trust_file).nil? && + config_value(:winrm_no_verify_cert) && + config_value(:winrm_ssl_peer_fingerprint).nil? + ui.warn <<~WARN + * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * + SSL validation of HTTPS requests for the WinRM transport is disabled. + HTTPS WinRM # connections are still encrypted, but knife is not able + to detect forged replies # or spoofing attacks. + + To fix this issue add an entry like this to your knife configuration file: + + # Verify all WinRM HTTPS connections (default, recommended) + knife[:winrm_no_verify_cert] = false + + You can also specify a ca_trust_file via --ca-trust-file, + or the expected fingerprint of the target host via + --winrm-ssl-peer-fingerprint. + WARN + end + end + + # + + # Create a configuration hash for TargetHost to connect + # to the remote host via Train. # - # @return Chef::Knife::Ssh - def knife_ssh_with_password_auth - ssh = knife_ssh - ssh.config[:ssh_identity_file] = nil - ssh.config[:ssh_password] = ssh.get_password - ssh + # @return a configuration hash suitable for connecting to the remote + # host via TargetHost. + def connection_opts + return @connection_opts unless @connection_opts.nil? + @connection_opts = {} + @connection_opts.merge! base_opts + @connection_opts.merge! host_verify_opts + @connection_opts.merge! gateway_opts + @connection_opts.merge! sudo_opts + @connection_opts.merge! winrm_opts + @connection_opts.merge! ssh_opts + @connection_opts.merge! ssh_identity_opts + @connection_opts end - # build the ssh dommand for bootrapping - # @return String - def ssh_command - command = render_template + # Common configuration for all protocols + def base_opts + # + port = config_value(:connection_port, + knife_key_for_protocol(connection_protocol, :port)) + user = config_value(:connection_user, + knife_key_for_protocol(connection_protocol, :user)) + {}.tap do |opts| + opts[:logger] = Chef::Log + # We do not store password in Chef::Config, so only use CLI `config` here + opts[:password] = config[:password] if config.key?(:password) + opts[:user] = user if user + opts[:max_wait_until_ready] = config_value(:max_wait) unless config_value(:max_wait).nil? + # TODO - when would we need to provide rdp_port vs port? Or are they not mutually exclusive? + opts[:port] = port if port + end + end + + def host_verify_opts + case connection_protocol + when "winrm" + { self_signed: config_value(:winrm_no_verify_cert) === true } + when "ssh" + # TODO is this a safe footgun to provide? Seems a security risk + # if someone forgets to If someone forgets ssh_verify_host_key is + # is in knife config, s- setting ssh_verify_host_key to true + # in knife.rb, and forgetting it's there? + { verify_host_key: config_value(:ssh_verify_host_key, + :host_key_verify, true) === true } + else + {} + end + end + + def ssh_opts + opts = {} + return opts if connection_protocol == "winrm" + opts[:forward_agent] = (config_value(:ssh_forward_agent) === true) + opts + end + def ssh_identity_opts + opts = {} + return opts if connection_protocol == "winrm" + identity_file = config_value(:ssh_identity_file) + if identity_file + opts[:key_files] = [identity_file] + # We only set keys_only based on the explicit ssh_identity_file; + # someone may use a gateway key and still expect password auth + # on the target. Similarly, someone may have a default key specified + # in knife config, but have provided a password on the CLI. + + # REVIEW NOTE: this is a new behavior. Originally, ssh_identity_file + # could only be populated from CLI options, so there was no need to check + # for this. We will also set keys_only to false only if there are keys + # and no password. + # If both are present, train(via net/ssh) will prefer keys, falling back to password. + # Reference: https://github.com/chef/chef/blob/master/lib/chef/knife/ssh.rb#L272 + opts[:keys_only] = config.key?(:password) == false + else + opts[:key_files] = [] + opts[:keys_only] = false + end + + gateway_identity_file = config_value(:ssh_gateway) ? config_value(:ssh_gateway_identity) : nil + unless gateway_identity_file.nil? + opts[:key_files] << gateway_identity_file + end + + opts + end + + def gateway_opts + opts = {} + if config_value(:ssh_gateway) + split = config_value(:ssh_gateway).split("@", 2) + if split.length == 1 + gw_host = split[0] + else + gw_user = split[0] + gw_host = split[1] + end + gw_host, gw_port = gw_host.split(":", 2) + # TODO - validate convertable port in config validation? + gw_port = Integer(gw_port) rescue nil + opts[:bastion_host] = gw_host + opts[:bastion_user] = gw_user + opts[:bastion_port] = gw_port + end + opts + end + + + # use_sudo - tells bootstrap to use the sudo command to run bootstrap + # use_sudo_password - tells bootstrap to use the sudo command to run bootstrap + # and to use the password specified with --password + # TODO: I'd like to make our sudo options sane: + # --sudo (bool) - use sudo + # --sudo-password PASSWORD (default: :password) - use this password for sudo + # --sudo-options "opt,opt,opt" to pass into sudo + # --sudo-command COMMAND sudo command other than sudo + # REVIEW NOTE: knife bootstrap did not pull sudo values from Chef::Config, + # should we change that for consistency? + def sudo_opts + return {} if connection_protocol == "winrm" + opts = { sudo: false } if config[:use_sudo] - sudo_prefix = config[:use_sudo_password] ? "echo '#{config[:ssh_password]}' | sudo -S " : "sudo " - command = config[:preserve_home] ? "#{sudo_prefix} #{command}" : "#{sudo_prefix} -H #{command}" + opts[:sudo] = true + if config[:use_sudo_password] + opts[:sudo_password] = config[:password] + end + if config[:preserve_home] + opts[:sudo_options] = "-H" + end + end + opts + end + + def winrm_opts + return {} unless connection_protocol == "winrm" + auth_method = config_value(:winrm_auth_method, :winrm_auth_method, "negotiate") + opts = { + winrm_transport: auth_method, # winrm gem and train calls auth method 'transport' + winrm_basic_auth_only: config_value(:winrm_basic_auth_only) || false, + ssl: config_value(:winrm_ssl) === true, + ssl_peer_fingerprint: config_value(:winrm_ssl_peer_fingerprint) + } + + if auth_method == "kerberos" + opts[:kerberos_service] = config_value(:kerberos_service) if config_value(:kerberos_service) + opts[:kerberos_realm] = config_value(:kerberos_realm) if config_value(:kerberos_service) + end + + if config_value(:ca_trust_file) + opts[:ca_trust_file] = config_value(:ca_trust_file) + end + + opts[:operation_timeout] = config_value(:winrm_session_timeout) || 60 + + opts + end + + + # Config overrides to force password auth. + def force_ssh_password_opts(password) + { + password: password, + non_interactive: false, + keys_only: false, + key_files: [], + auth_methods: [:password, :keyboard_interactive] + } + end + + # Looks up configuration entries, first in the class member + # `config` which contains options populated from CLI flags. + # If the entry is not found there, Chef::Config[:knife][KEY] + # is checked. + # + # knife_config_key should be specified if the knife config lookup + # key is different from the CLI flag lookup key. + # + def config_value(key, knife_config_key = nil, default = nil) + if config.key? key + config[key] + else + lookup_key = knife_config_key || key + if Chef::Config[:knife].key?(lookup_key) + Chef::Config[:knife][lookup_key] + else + default + end end + end - command + # Tells us where a config value has come from , + # :cli_config, :knife_config, :not_found + def config_source(key, knife_config_key = nil) + return :cli_config if config.key? key + return :knife_config if config.key?(key) || config.key?(knife_config_key) + :not_found + end + + + def upload_bootstrap(content) + script_name = target_host.base_os == :windows ? "bootstrap.bat" : "bootstrap.sh" + remote_path = target_host.normalize_path(File.join(target_host.temp_dir, script_name)) + target_host.save_as_remote_file(content, remote_path) + remote_path + end + + + # build the command string for bootrapping + # @return String + def bootstrap_command(remote_path) + if target_host.base_os == :windows + "cmd.exe /C #{remote_path}" + else + "sh #{remote_path}" + end + end + + + # To avoid cluttering the CLI options, some flags (such as port and user) + # are shared between protocols. However, there is still a need to allow the operator + # to specify defaults separately, since they may not be the same values for different protocols. + # + # These keys are available in Chef::Config, and are prefixed with the protocol name. + # For example, :user CLI option will map to :winrm_user and :ssh_user Chef::Config keys, + # based on the connection protocol in use. + def knife_key_for_protocol(protocol, option) + "#{connection_protocol}_#{option.to_s}".to_sym end private @@ -487,7 +643,6 @@ class Chef def incomplete_policyfile_options? (!!config[:policy_name] ^ config[:policy_group]) end - end end end diff --git a/lib/chef/knife/bootstrap/options.rb b/lib/chef/knife/bootstrap/options.rb new file mode 100644 index 0000000000..350aef0959 --- /dev/null +++ b/lib/chef/knife/bootstrap/options.rb @@ -0,0 +1,358 @@ +# Author:: Marc Paradise () +# Copyright:: Copyright 2019, Chef Software Inc. +# License:: Apache License, Version 2.0 +# +# 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. +# + +class Chef + class Knife + class Bootstrap + module Options + # REVIEWER: let's talk about which protocols we want to support. + # TODO this should be available from train, which we can make our source of truth. + # TODO - we don't actually validate that the protocol is valid... + WINRM_AUTH_PROTOCOL_LIST = %w{plaintext kerberos ssl negotiate} + + #TODO - missing - authtimeout (minutes) + #TODO - missing impl - session-timeout minutes + def self.included(includer) + includer.class_eval do + + # Common connectivity options + option :connection_user, + short: "-U USERNAME", + long: "--connection-user USERNAME", + description: "Authenticate to the target host with this user account" + + option :password, + short: "-P PASSWORD", + long: "--connection-password PASSWORD", + description: "Authenticate to the target host with this password" + + option :connection_port, + short: "-p PORT", + long: "--connection-port PORT", + description: "The port on the target node to connect to." + + option :connection_protocol, + short: "-o PROTOCOL", + long: "--connection-protocol PROTOCOL", + description: "The protocol to use to connect to the target node. Supports ssh and winrm." + + option :max_wait, + short: "-W SECONDS", + long: "--max-wait SECONDS", + description: "The maximum time to wait for the initial connection to be established." + + ## SSH options + + option :ssh_gateway, + short: "-G GATEWAY", + long: "--ssh-gateway GATEWAY", + description: "The ssh gateway", + proc: Proc.new { |key| Chef::Config[:knife][:ssh_gateway] = key } + + option :ssh_gateway_identity, + long: "--ssh-gateway-identity SSH_GATEWAY_IDENTITY", + description: "The SSH identity file used for gateway authentication", + proc: Proc.new { |key| Chef::Config[:knife][:ssh_gateway_identity] = key } + + # SSH train ssh: options[:forward_agent] + option :ssh_forward_agent, + short: "-A", + long: "--ssh-forward-agent", + description: "Enable SSH agent forwarding", + boolean: true + + # SSH train: options[key_files] + option :ssh_identity_file, + short: "-i IDENTITY_FILE", + long: "--ssh-identity-file IDENTITY_FILE", + description: "The SSH identity file used for authentication" + + # ssh options - train options[:verify_host_key] + option :ssh_verify_host_key, + long: "--ssh-[no-]verify-host-key", + description: "Verify host key, enabled by default.", + boolean: true + + # argument to installer in chef-full, via bootstrap_context + option :prerelease, + long: "--prerelease", + description: "Install the pre-release chef gems" + + # client.rb content via chef-full/bootstrap_context + option :bootstrap_version, + long: "--bootstrap-version VERSION", + description: "The version of Chef to install", + proc: lambda { |v| Chef::Config[:knife][:bootstrap_version] = v } + + # client.rb content via chef-full/bootstrap_context + option :bootstrap_proxy, + long: "--bootstrap-proxy PROXY_URL", + description: "The proxy server for the node being bootstrapped", + proc: Proc.new { |p| Chef::Config[:knife][:bootstrap_proxy] = p } + + # client.rb content via bootstrap_context + option :bootstrap_proxy_user, + long: "--bootstrap-proxy-user PROXY_USER", + description: "The proxy authentication username for the node being bootstrapped" + + # client.rb content via bootstrap_context + option :bootstrap_proxy_pass, + long: "--bootstrap-proxy-pass PROXY_PASS", + description: "The proxy authentication password for the node being bootstrapped" + + # client.rb content via bootstrap_context + option :bootstrap_no_proxy, + long: "--bootstrap-no-proxy [NO_PROXY_URL|NO_PROXY_IP]", + description: "Do not proxy locations for the node being bootstrapped; this option is used internally by Chef", + proc: Proc.new { |np| Chef::Config[:knife][:bootstrap_no_proxy] = np } + + # client.rb content via bootstrap_context + option :bootstrap_template, + short: "-t TEMPLATE", + long: "--bootstrap-template TEMPLATE", + description: "Bootstrap Chef using a built-in or custom template. Set to the full path of an erb template or use one of the built-in templates." + + # client.rb content via bootstrap_context + option :node_ssl_verify_mode, + long: "--node-ssl-verify-mode [peer|none]", + description: "Whether or not to verify the SSL cert for all HTTPS requests.", + proc: Proc.new { |v| + valid_values = %w{none peer} + unless valid_values.include?(v) + raise "Invalid value '#{v}' for --node-ssl-verify-mode. Valid values are: #{valid_values.join(", ")}" + end + v + } + + # bootstrap_context - client.rb + option :node_verify_api_cert, + long: "--[no-]node-verify-api-cert", + description: "Verify the SSL cert for HTTPS requests to the Chef server API.", + boolean: true + + # runtime - sudo settings (train handles sudo) + option :use_sudo, + long: "--sudo", + description: "Execute the bootstrap via sudo", + boolean: true + + # runtime - sudo settings (train handles sudo) + option :preserve_home, + long: "--sudo-preserve-home", + description: "Preserve non-root user HOME environment variable with sudo", + boolean: true + + # runtime - sudo settings (train handles sudo) + option :use_sudo_password, + long: "--use-sudo-password", + description: "Execute the bootstrap via sudo with password", + boolean: false + + # runtime - client_builder + option :chef_node_name, + short: "-N NAME", + long: "--node-name NAME", + description: "The Chef node name for your new node" + + # runtime - client_builder - set runlist when creating node + option :run_list, + short: "-r RUN_LIST", + long: "--run-list RUN_LIST", + description: "Comma separated list of roles/recipes to apply", + proc: lambda { |o| o.split(/[\s,]+/) }, + default: [] + + # runtime - client_builder - set policy name when creating node + option :policy_name, + long: "--policy-name POLICY_NAME", + description: "Policyfile name to use (--policy-group must also be given)", + default: nil + + # runtime - client_builder - set policy group when creating node + option :policy_group, + long: "--policy-group POLICY_GROUP", + description: "Policy group name to use (--policy-name must also be given)", + default: nil + + # runtime - client_builder - node tags + option :tags, + long: "--tags TAGS", + description: "Comma separated list of tags to apply to the node", + proc: lambda { |o| o.split(/[\s,]+/) }, + default: [] + + # bootstrap template + option :first_boot_attributes, + short: "-j JSON_ATTRIBS", + long: "--json-attributes", + description: "A JSON string to be added to the first run of chef-client", + proc: lambda { |o| Chef::JSONCompat.parse(o) }, + default: nil + + # bootstrap template + option :first_boot_attributes_from_file, + long: "--json-attribute-file FILE", + description: "A JSON file to be used to the first run of chef-client", + proc: lambda { |o| Chef::JSONCompat.parse(File.read(o)) }, + default: nil + + # Note that several of the below options are used by bootstrap template, + # but only from the passed-in knife config; it does not use the + # config from the CLI for those values. In those cases, the option + # will have a proc that assigns the value into Chef::Config[:knife] + + # bootstrap template + # Create ohai hints in /etc/chef/ohai/hints, fname=hintname, content=value + option :hint, + long: "--hint HINT_NAME[=HINT_FILE]", + description: "Specify Ohai Hint to be set on the bootstrap target. Use multiple --hint options to specify multiple hints.", + proc: Proc.new { |h| + Chef::Config[:knife][:hints] ||= Hash.new + name, path = h.split("=") + Chef::Config[:knife][:hints][name] = path ? Chef::JSONCompat.parse(::File.read(path)) : Hash.new + } + + # bootstrap override: url of a an installer shell script touse in place of omnitruck + # Note that the bootstrap template _only_ references this out of Chef::Config, and not from + # the provided options to knife bootstrap, so we set the Chef::Config option here. + option :bootstrap_url, + long: "--bootstrap-url URL", + description: "URL to a custom installation script", + proc: Proc.new { |u| Chef::Config[:knife][:bootstrap_url] = u } + + + # bootstrap override: Do this instead of our own setup.sh from omnitruck. Causes bootstrap_url to be ignored. + option :bootstrap_install_command, + long: "--bootstrap-install-command COMMANDS", + description: "Custom command to install chef-client", + proc: Proc.new { |ic| Chef::Config[:knife][:bootstrap_install_command] = ic } + + # bootstrap template: Run this command first in the bootstrap script + option :bootstrap_preinstall_command, + long: "--bootstrap-preinstall-command COMMANDS", + description: "Custom commands to run before installing chef-client", + proc: Proc.new { |preic| Chef::Config[:knife][:bootstrap_preinstall_command] = preic } + + # bootstrap template + option :bootstrap_wget_options, + long: "--bootstrap-wget-options OPTIONS", + description: "Add options to wget when installing chef-client", + proc: Proc.new { |wo| Chef::Config[:knife][:bootstrap_wget_options] = wo } + + # bootstrap template + option :bootstrap_curl_options, + long: "--bootstrap-curl-options OPTIONS", + description: "Add options to curl when install chef-client", + proc: Proc.new { |co| Chef::Config[:knife][:bootstrap_curl_options] = co } + + # chef_vault_handler + option :bootstrap_vault_file, + long: "--bootstrap-vault-file VAULT_FILE", + description: "A JSON file with a list of vault(s) and item(s) to be updated" + + # chef_vault_handler + option :bootstrap_vault_json, + long: "--bootstrap-vault-json VAULT_JSON", + description: "A JSON string with the vault(s) and item(s) to be updated" + + # chef_vault_handler + option :bootstrap_vault_item, + long: "--bootstrap-vault-item VAULT_ITEM", + description: 'A single vault and item to update as "vault:item"', + proc: Proc.new { |i| + (vault, item) = i.split(/:/) + Chef::Config[:knife][:bootstrap_vault_item] ||= {} + Chef::Config[:knife][:bootstrap_vault_item][vault] ||= [] + Chef::Config[:knife][:bootstrap_vault_item][vault].push(item) + Chef::Config[:knife][:bootstrap_vault_item] + } + + # Windows only + + # bootstrap template + option :install_as_service, + :long => "--install-as-service", + :description => "Install chef-client as a Windows service. (Windows only)", + :default => false + + # bootstrap template + option :msi_url, + :short => "-m URL", + :long => "--msi-url URL", + :description => "Location of the Chef Client MSI. The default templates will prefer to download from this location. The MSI will be downloaded from chef.io if not provided (windows).", + :default => '' + + option :winrm_ssl_peer_fingerprint, + :long => "--winrm-ssl-peer-fingerprint FINGERPRINT", + :description => "SSL certificate fingerprint expected from the target." + + option :ca_trust_file, + :short => "-f CA_TRUST_PATH", + :long => "--ca-trust-file CA_TRUST_PATH", + :description => "The Certificate Authority (CA) trust file used for SSL transport" + + option :winrm_no_verify_cert, + long: "--winrm-no-verify-cert", + description: "Do not verify the SSL certificate of the target node for WinRM." + + + option :winrm_ssl, + long: "--winrm-ssl", + description: "Connect to WinRM using SSL" + + option :winrm_auth_method, + :short => "-w AUTH-METHOD", + :long => "--winrm-auth-method AUTH-METHOD", + :description => "The WinRM authentication method to use. Valid choices are #{WINRM_AUTH_PROTOCOL_LIST}", + :proc => Proc.new { |protocol| Chef::Config[:knife][:winrm_auth_method] = protocol } + + option :winrm_basic_auth_only, + long: "--winrm-basic-auth-only", + description: "For WinRM basic authentication when using the 'ssl' auth method", + boolean: true + + # This option was provided in knife bootstrap windows winrm, + # but it is ignored in knife-windows/WinrmSession, and so remains unimplemeneted here. + # option :kerberos_keytab_file, + # :short => "-T KEYTAB_FILE", + # :long => "--keytab-file KEYTAB_FILE", + # :description => "The Kerberos keytab file used for authentication", + # :proc => Proc.new { |keytab| Chef::Config[:knife][:kerberos_keytab_file] = keytab } + + option :kerberos_realm, + :short => "-R KERBEROS_REALM", + :long => "--kerberos-realm KERBEROS_REALM", + :description => "The Kerberos realm used for authentication", + :proc => Proc.new { |protocol| Chef::Config[:knife][:kerberos_realm] = protocol } + + option :kerberos_service, + :short => "-S KERBEROS_SERVICE", + :long => "--kerberos-service KERBEROS_SERVICE", + :description => "The Kerberos service used for authentication", + :proc => Proc.new { |protocol| Chef::Config[:knife][:kerberos_service] = protocol } + + option :winrm_session_timeout, + :long => "--winrm-session-timeout SECONDS", + :description => "The number of seconds to wait for each WinRM operation to be acknowledged while running bootstrap", + :proc => Proc.new { |protocol| Chef::Config[:knife][:winrm_session_timeout] = protocol } + + end + end + end + end + end +end diff --git a/lib/chef/knife/bootstrap/templates/windows-chef-client-msi.erb b/lib/chef/knife/bootstrap/templates/windows-chef-client-msi.erb new file mode 100644 index 0000000000..c30a22bd94 --- /dev/null +++ b/lib/chef/knife/bootstrap/templates/windows-chef-client-msi.erb @@ -0,0 +1,271 @@ +@rem +@rem Author:: Seth Chisamore () +@rem Copyright:: Copyright (c) 2011-2017 Chef Software, Inc. +@rem License:: Apache License, Version 2.0 +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem http://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@rem Use delayed environment expansion so that ERRORLEVEL can be evaluated with the +@rem !ERRORLEVEL! syntax which evaluates at execution of the line of script, not when +@rem the line is read. See help for the /E switch from cmd.exe /? . +@setlocal ENABLEDELAYEDEXPANSION + +<%= "SETX HTTP_PROXY \"#{knife_config[:bootstrap_proxy]}\"" if knife_config[:bootstrap_proxy] %> + +@set BOOTSTRAP_DIRECTORY=<%= bootstrap_directory %> +@echo Checking for existing directory "%BOOTSTRAP_DIRECTORY%"... +@if NOT EXIST %BOOTSTRAP_DIRECTORY% ( + @echo Existing directory not found, creating. + @mkdir %BOOTSTRAP_DIRECTORY% +) else ( + @echo Existing directory found, skipping creation. +) + +> <%= bootstrap_directory %>\wget.vbs ( + <%= win_wget %> +) + +> <%= bootstrap_directory %>\wget.ps1 ( + <%= win_wget_ps %> +) + +@rem Determine the version and the architecture + +@FOR /F "usebackq tokens=1-8 delims=.[] " %%A IN (`ver`) DO ( +@set WinMajor=%%D +@set WinMinor=%%E +@set WinBuild=%%F +) + +@echo Detected Windows Version %WinMajor%.%WinMinor% Build %WinBuild% + +@set LATEST_OS_VERSION_MAJOR=10 +@set LATEST_OS_VERSION_MINOR=1 + +@if /i %WinMajor% GTR %LATEST_OS_VERSION_MAJOR% goto VersionUnknown +@if /i %WinMajor% EQU %LATEST_OS_VERSION_MAJOR% ( + @if /i %WinMinor% GTR %LATEST_OS_VERSION_MINOR% goto VersionUnknown +) + +goto Version%WinMajor%.%WinMinor% + +:VersionUnknown +@rem If this is an unknown version of windows set the default +@set MACHINE_OS=2012r2 +@echo Warning: Unknown version of Windows, assuming default of Windows %MACHINE_OS% +goto architecture_select + +:Version6.0 +@set MACHINE_OS=2008 +goto architecture_select + +:Version5.2 +@set MACHINE_OS=2003r2 +goto architecture_select + +:Version6.1 +@set MACHINE_OS=2008r2 +goto architecture_select + +:Version6.2 +@set MACHINE_OS=2012 +goto architecture_select + +@rem Currently Windows Server 2012 R2 is treated as equivalent to Windows Server 2012 +:Version6.3 +@set MACHINE_OS=2012r2 +goto architecture_select + +:Version10.0 +@set MACHINE_OS=2016 +goto architecture_select + +@rem Currently Windows Server 2016 R2 is treated as equivalent to Windows Server 2016 +:Version10.1 +goto Version10.0 + +:architecture_select +<% if knife_config[:architecture] %> + @set MACHINE_ARCH=<%= knife_config[:architecture] %> + + <% if knife_config[:architecture] == "x86_64" %> + IF "%PROCESSOR_ARCHITECTURE%"=="x86" IF not defined PROCESSOR_ARCHITEW6432 ( + echo You specified bootstrap_architecture as x86_64 but the target machine is i386. A 64 bit program cannot run on a 32 bit machine. > "&2" + echo Exiting without bootstrapping. > "&2" + exit /b 1 + ) + <% end %> +<% else %> + @set MACHINE_ARCH=x86_64 + IF "%PROCESSOR_ARCHITECTURE%"=="x86" IF not defined PROCESSOR_ARCHITEW6432 @set MACHINE_ARCH=i686 +<% end %> +goto chef_installed + +:chef_installed +@echo Checking for existing chef installation +WHERE chef-client >nul 2>nul +If !ERRORLEVEL!==0 ( + @echo Existing Chef installation detected, skipping download + goto key_create +) else ( + @echo No existing installation of chef detected + goto install +) + +:install +@rem If user has provided the custom installation command for chef-client then execute it +<% if @chef_config[:knife][:bootstrap_install_command] %> + <%= @chef_config[:knife][:bootstrap_install_command] %> +<% else %> + @rem Install Chef using chef-client MSI installer + + @set "LOCAL_DESTINATION_MSI_PATH=<%= local_download_path %>" + @set "CHEF_CLIENT_MSI_LOG_PATH=%TEMP%\chef-client-msi%RANDOM%.log" + + @rem Clear any pre-existing downloads + @echo Checking for existing downloaded package at "%LOCAL_DESTINATION_MSI_PATH%" + @if EXIST "%LOCAL_DESTINATION_MSI_PATH%" ( + @echo Found existing downloaded package, deleting. + @del /f /q "%LOCAL_DESTINATION_MSI_PATH%" + @if ERRORLEVEL 1 ( + echo Warning: Failed to delete pre-existing package with status code !ERRORLEVEL! > "&2" + ) + ) else ( + echo No existing downloaded packages to delete. + ) + + @rem If there is somehow a name collision, remove pre-existing log + @if EXIST "%CHEF_CLIENT_MSI_LOG_PATH%" del /f /q "%CHEF_CLIENT_MSI_LOG_PATH%" + + @echo Attempting to download client package using PowerShell if available... + @set "REMOTE_SOURCE_MSI_URL=<%= msi_url('%MACHINE_OS%', '%MACHINE_ARCH%', 'PowerShell') %>" + @set powershell_download=powershell.exe -ExecutionPolicy Unrestricted -InputFormat None -NoProfile -NonInteractive -File <%= bootstrap_directory %>\wget.ps1 "%REMOTE_SOURCE_MSI_URL%" "%LOCAL_DESTINATION_MSI_PATH%" + @echo !powershell_download! + @call !powershell_download! + + @set DOWNLOAD_ERROR_STATUS=!ERRORLEVEL! + + @if ERRORLEVEL 1 ( + @echo Failed PowerShell download with status code !DOWNLOAD_ERROR_STATUS! > "&2" + @if !DOWNLOAD_ERROR_STATUS!==0 set DOWNLOAD_ERROR_STATUS=2 + ) else ( + @rem Sometimes the error level is not set even when the download failed, + @rem so check for the file to be sure it is there -- if it is not, we will retry + @if NOT EXIST "%LOCAL_DESTINATION_MSI_PATH%" ( + echo Failed download: download completed, but downloaded file not found > "&2" + set DOWNLOAD_ERROR_STATUS=2 + ) else ( + echo Download via PowerShell succeeded. + ) + ) + + @if NOT %DOWNLOAD_ERROR_STATUS%==0 ( + @echo Warning: Failed to download "%REMOTE_SOURCE_MSI_URL%" to "%LOCAL_DESTINATION_MSI_PATH%" + @echo Warning: Retrying download with cscript ... + + @if EXIST "%LOCAL_DESTINATION_MSI_PATH%" del /f /q "%LOCAL_DESTINATION_MSI_PATH%" + + @set "REMOTE_SOURCE_MSI_URL=<%= msi_url('%MACHINE_OS%', '%MACHINE_ARCH%') %>" + cscript /nologo <%= bootstrap_directory %>\wget.vbs /url:"%REMOTE_SOURCE_MSI_URL%" /path:"%LOCAL_DESTINATION_MSI_PATH%" + + @if NOT ERRORLEVEL 1 ( + @rem Sometimes the error level is not set even when the download failed, + @rem so check for the file to be sure it is there. + @if NOT EXIST "%LOCAL_DESTINATION_MSI_PATH%" ( + echo Failed download: download completed, but downloaded file not found > "&2" + echo Exiting without bootstrapping due to download failure. > "&2" + exit /b 1 + ) else ( + echo Download via cscript succeeded. + ) + ) else ( + echo Failed to download "%REMOTE_SOURCE_MSI_URL%" with status code !ERRORLEVEL!. > "&2" + echo Exiting without bootstrapping due to download failure. > "&2" + exit /b 1 + ) + ) + + @echo Installing downloaded client package... + + <%= install_chef %> + + @if ERRORLEVEL 1 ( + echo Chef-client package failed to install with status code !ERRORLEVEL!. > "&2" + echo See installation log for additional detail: %CHEF_CLIENT_MSI_LOG_PATH%. > "&2" + ) else ( + @echo Installation completed successfully + del /f /q "%CHEF_CLIENT_MSI_LOG_PATH%" + ) + +<% end %> + +:key_create +@endlocal + +@echo off + +<% if client_pem -%> +> <%= bootstrap_directory %>\client.pem ( + <%= escape_and_echo(::File.read(::File.expand_path(client_pem))) %> +) +<% end -%> + +echo Writing validation key... + +<% if validation_key -%> +> <%= bootstrap_directory %>\validation.pem ( + <%= escape_and_echo(validation_key) %> +) +<% end -%> + +echo Validation key written. +@echo on + +<% if @config[:secret] -%> +> <%= bootstrap_directory %>\encrypted_data_bag_secret ( + <%= secret %> +) +<% end -%> + +<% unless trusted_certs_script.empty? -%> +mkdir <%= bootstrap_directory %>\trusted_certs +<%= trusted_certs_script %> +<% end -%> + +<%# Generate Ohai Hints -%> +<% unless @chef_config[:knife][:hints].nil? || @chef_config[:knife][:hints].empty? -%> +mkdir <%= bootstrap_directory %>\ohai\hints + +<% @chef_config[:knife][:hints].each do |name, hash| -%> +> <%= bootstrap_directory %>\ohai\hints\<%= name %>.json ( + <%= escape_and_echo(hash.to_json) %> +) +<% end -%> +<% end -%> + +> <%= bootstrap_directory %>\client.rb ( + <%= config_content %> +) + +> <%= bootstrap_directory %>\first-boot.json ( + <%= first_boot %> +) + +<% unless client_d.empty? -%> + mkdir <%= bootstrap_directory %>\client.d + <%= client_d %> +<% end -%> + +@echo Starting chef to bootstrap the node... +<%= start_chef %> diff --git a/lib/chef/knife/core/bootstrap_context.rb b/lib/chef/knife/core/bootstrap_context.rb index bd094e5021..287fe0a50f 100644 --- a/lib/chef/knife/core/bootstrap_context.rb +++ b/lib/chef/knife/core/bootstrap_context.rb @@ -161,7 +161,7 @@ class Chef end if Chef::Config[:fips] - client_rb << <<-CONFIG.gsub(/^ {14}/, "") + client_rb << <<~CONFIG fips true require "chef/version" chef_version = ::Chef::VERSION.split(".") diff --git a/lib/chef/knife/core/ui.rb b/lib/chef/knife/core/ui.rb index 3f0a697107..c696378912 100644 --- a/lib/chef/knife/core/ui.rb +++ b/lib/chef/knife/core/ui.rb @@ -85,6 +85,13 @@ class Chef alias :info :log alias :err :log + # Print a Debug + # + # @param message [String] the text string + def debug(message) + log("#{color('DEBUG:', :blue, :bold)} #{message}") + end + # Print a warning message # # @param message [String] the text string diff --git a/lib/chef/knife/core/windows_bootstrap_context.rb b/lib/chef/knife/core/windows_bootstrap_context.rb new file mode 100644 index 0000000000..6db017ca2f --- /dev/null +++ b/lib/chef/knife/core/windows_bootstrap_context.rb @@ -0,0 +1,412 @@ +# +# Author:: Seth Chisamore () +# Copyright:: Copyright (c) 2011-2016 Chef Software, Inc. +# License:: Apache License, Version 2.0 +# +# 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. +# + +require 'chef/knife/core/bootstrap_context' +require 'chef/util/path_helper' + +class Chef + class Knife + module Core + # Instances of BootstrapContext are the context objects (i.e., +self+) for + # bootstrap templates. For backwards compatability, they +must+ set the + # following instance variables: + # * @config - a hash of knife's config values + # * @run_list - the run list for the node to boostrap + # + class WindowsBootstrapContext < BootstrapContext + + def initialize(config, run_list, chef_config, secret=nil) + @config = config + @run_list = run_list + @chef_config = chef_config + @secret = secret + # Compatibility with Chef 12 and Chef 11 versions + begin + # Pass along the secret parameter for Chef 12 + super(config, run_list, chef_config, secret) + rescue ArgumentError + # The Chef 11 base class only has parameters for initialize + super(config, run_list, chef_config) + end + end + + def validation_key + if File.exist?(File.expand_path(@chef_config[:validation_key])) + IO.read(File.expand_path(@chef_config[:validation_key])) + else + false + end + end + + def secret + escape_and_echo(@config[:secret]) + end + + def trusted_certs_script + @trusted_certs_script ||= trusted_certs_content + end + + def config_content + client_rb = <<~CONFIG + chef_server_url "#{@chef_config[:chef_server_url]}" + validation_client_name "#{@chef_config[:validation_client_name]}" + file_cache_path "c:/chef/cache" + file_backup_path "c:/chef/backup" + cache_options ({:path => "c:/chef/cache/checksums", :skip_expires => true}) + CONFIG + if @config[:chef_node_name] + client_rb << %Q{node_name "#{@config[:chef_node_name]}"\n} + else + client_rb << "# Using default node name (fqdn)\n" + end + + if @chef_config[:config_log_level] + client_rb << %Q{log_level :#{@chef_config[:config_log_level]}\n} + else + client_rb << "log_level :auto\n" + end + + client_rb << "log_location #{get_log_location}" + + # We configure :verify_api_cert only when it's overridden on the CLI + # or when specified in the knife config. + if !@config[:node_verify_api_cert].nil? || knife_config.has_key?(:verify_api_cert) + value = @config[:node_verify_api_cert].nil? ? knife_config[:verify_api_cert] : @config[:node_verify_api_cert] + client_rb << %Q{verify_api_cert #{value}\n} + end + + # We configure :ssl_verify_mode only when it's overridden on the CLI + # or when specified in the knife config. + if @config[:node_ssl_verify_mode] || knife_config.has_key?(:ssl_verify_mode) + value = case @config[:node_ssl_verify_mode] + when "peer" + :verify_peer + when "none" + :verify_none + when nil + knife_config[:ssl_verify_mode] + else + nil + end + + if value + client_rb << %Q{ssl_verify_mode :#{value}\n} + end + end + + if @config[:ssl_verify_mode] + client_rb << %Q{ssl_verify_mode :#{knife_config[:ssl_verify_mode]}\n} + end + + if knife_config[:bootstrap_proxy] + client_rb << "\n" + client_rb << %Q{http_proxy "#{knife_config[:bootstrap_proxy]}"\n} + client_rb << %Q{https_proxy "#{knife_config[:bootstrap_proxy]}"\n} + client_rb << %Q{no_proxy "#{knife_config[:bootstrap_no_proxy]}"\n} if knife_config[:bootstrap_no_proxy] + end + + if knife_config[:bootstrap_no_proxy] + client_rb << %Q{no_proxy "#{knife_config[:bootstrap_no_proxy]}"\n} + end + + if @config[:secret] + client_rb << %Q{encrypted_data_bag_secret "c:/chef/encrypted_data_bag_secret"\n} + end + + unless trusted_certs_script.empty? + client_rb << %Q{trusted_certs_dir "c:/chef/trusted_certs"\n} + end + + if Chef::Config[:fips] + client_rb << <<~CONFIG + fips true + chef_version = ::Chef::VERSION.split(".") + unless chef_version[0].to_i > 12 || (chef_version[0].to_i == 12 && chef_version[1].to_i >= 8) + raise "FIPS Mode requested but not supported by this client" + end + CONFIG + end + + escape_and_echo(client_rb) + end + + def get_log_location + if @chef_config[:config_log_location].equal?(:win_evt) + %Q{:#{@chef_config[:config_log_location]}\n} + elsif @chef_config[:config_log_location].equal?(:syslog) + raise "syslog is not supported for log_location on Windows OS\n" + elsif (@chef_config[:config_log_location].equal?(STDOUT)) + "STDOUT\n" + elsif (@chef_config[:config_log_location].equal?(STDERR)) + "STDERR\n" + elsif @chef_config[:config_log_location].nil? || @chef_config[:config_log_location].empty? + "STDOUT\n" + elsif @chef_config[:config_log_location] + %Q{"#{@chef_config[:config_log_location]}"\n} + else + "STDOUT\n" + end + end + + def start_chef + bootstrap_environment_option = bootstrap_environment.nil? ? '' : " -E #{bootstrap_environment}" + start_chef = "SET \"PATH=%SystemRoot%\\system32;%SystemRoot%;%SystemRoot%\\System32\\Wbem;%SYSTEMROOT%\\System32\\WindowsPowerShell\\v1.0\\;C:\\ruby\\bin;C:\\opscode\\chef\\bin;C:\\opscode\\chef\\embedded\\bin\"\n" + start_chef << "chef-client -c c:/chef/client.rb -j c:/chef/first-boot.json#{bootstrap_environment_option}\n" + end + + def latest_current_windows_chef_version_query + installer_version_string = nil + if @config[:prerelease] + installer_version_string = "&prerelease=true" + else + chef_version_string = if knife_config[:bootstrap_version] + knife_config[:bootstrap_version] + else + Chef::VERSION.split(".").first + end + + installer_version_string = "&v=#{chef_version_string}" + + # If bootstrapping a pre-release version add the prerelease query string + if chef_version_string.split(".").length > 3 + installer_version_string << "&prerelease=true" + end + end + + installer_version_string + end + + def win_wget + # I tried my best to figure out how to properly url decode and switch / to \ + # but this is VBScript - so I don't really care that badly. + win_wget = <<~WGET + url = WScript.Arguments.Named("url") + path = WScript.Arguments.Named("path") + proxy = null + '* Vaguely attempt to handle file:// scheme urls by url unescaping and switching all + '* / into \. Also assume that file:/// is a local absolute path and that file:// + '* is possibly a network file path. + If InStr(url, "file://") = 1 Then + url = Unescape(url) + If InStr(url, "file:///") = 1 Then + sourcePath = Mid(url, Len("file:///") + 1) + Else + sourcePath = Mid(url, Len("file:") + 1) + End If + sourcePath = Replace(sourcePath, "/", "\\") + + Set objFSO = CreateObject("Scripting.FileSystemObject") + If objFSO.Fileexists(path) Then objFSO.DeleteFile path + objFSO.CopyFile sourcePath, path, true + Set objFSO = Nothing + + Else + Set objXMLHTTP = CreateObject("MSXML2.ServerXMLHTTP") + Set wshShell = CreateObject( "WScript.Shell" ) + Set objUserVariables = wshShell.Environment("USER") + + rem http proxy is optional + rem attempt to read from HTTP_PROXY env var first + On Error Resume Next + + If NOT (objUserVariables("HTTP_PROXY") = "") Then + proxy = objUserVariables("HTTP_PROXY") + + rem fall back to named arg + ElseIf NOT (WScript.Arguments.Named("proxy") = "") Then + proxy = WScript.Arguments.Named("proxy") + End If + + If NOT isNull(proxy) Then + rem setProxy method is only available on ServerXMLHTTP 6.0+ + Set objXMLHTTP = CreateObject("MSXML2.ServerXMLHTTP.6.0") + objXMLHTTP.setProxy 2, proxy + End If + + On Error Goto 0 + + objXMLHTTP.open "GET", url, false + objXMLHTTP.send() + If objXMLHTTP.Status = 200 Then + Set objADOStream = CreateObject("ADODB.Stream") + objADOStream.Open + objADOStream.Type = 1 + objADOStream.Write objXMLHTTP.ResponseBody + objADOStream.Position = 0 + Set objFSO = Createobject("Scripting.FileSystemObject") + If objFSO.Fileexists(path) Then objFSO.DeleteFile path + Set objFSO = Nothing + objADOStream.SaveToFile path + objADOStream.Close + Set objADOStream = Nothing + End If + Set objXMLHTTP = Nothing + End If + WGET + escape_and_echo(win_wget) + end + + def win_wget_ps + win_wget_ps = <<~WGET_PS + param( + [String] $remoteUrl, + [String] $localPath + ) + + $ProxyUrl = $env:http_proxy; + $webClient = new-object System.Net.WebClient; + + if ($ProxyUrl -ne '') { + $WebProxy = New-Object System.Net.WebProxy($ProxyUrl,$true) + $WebClient.Proxy = $WebProxy + } + + $webClient.DownloadFile($remoteUrl, $localPath); + WGET_PS + + escape_and_echo(win_wget_ps) + end + + def install_chef + # The normal install command uses regular double quotes in + # the install command, so request such a string from install_command + install_chef = install_command('"') + "\n" + fallback_install_task_command + end + + def bootstrap_directory + bootstrap_directory = "C:\\chef" + end + + def local_download_path + local_download_path = "%TEMP%\\chef-client-latest.msi" + end + + def msi_url(machine_os=nil, machine_arch=nil, download_context=nil) + # The default msi path has a number of url query parameters - we attempt to substitute + # such parameters in as long as they are provided by the template. + + if @config[:install].nil? || @config[:msi_url].empty? + url = "https://www.chef.io/chef/download?p=windows" + url += "&pv=#{machine_os}" unless machine_os.nil? + url += "&m=#{machine_arch}" unless machine_arch.nil? + url += "&DownloadContext=#{download_context}" unless download_context.nil? + url += latest_current_windows_chef_version_query + else + @config[:msi_url] + end + end + + def first_boot + escape_and_echo(super.to_json) + end + + # escape WIN BATCH special chars + # and prefixes each line with an + # echo + def escape_and_echo(file_contents) + file_contents.gsub(/^(.*)$/, 'echo.\1').gsub(/([(<|>)^])/, '^\1') + end + + private + + def install_command(executor_quote) + if @config[:install_as_service] + "msiexec /qn /log #{executor_quote}%CHEF_CLIENT_MSI_LOG_PATH%#{executor_quote} /i #{executor_quote}%LOCAL_DESTINATION_MSI_PATH%#{executor_quote} ADDLOCAL=#{executor_quote}ChefClientFeature,ChefServiceFeature#{executor_quote}" + else + "msiexec /qn /log #{executor_quote}%CHEF_CLIENT_MSI_LOG_PATH%#{executor_quote} /i #{executor_quote}%LOCAL_DESTINATION_MSI_PATH%#{executor_quote}" + end + end + + # Returns a string for copying the trusted certificates on the workstation to the system being bootstrapped + # This string should contain both the commands necessary to both create the files, as well as their content + def trusted_certs_content + content = "" + if @chef_config[:trusted_certs_dir] + Dir.glob(File.join(Chef::Util::PathHelper.escape_glob_dir(@chef_config[:trusted_certs_dir]), "*.{crt,pem}")).each do |cert| + content << "> #{bootstrap_directory}/trusted_certs/#{File.basename(cert)} (\n" + + escape_and_echo(IO.read(File.expand_path(cert))) + "\n)\n" + end + end + content + end + + def client_d_content + content = "" + if @chef_config[:client_d_dir] && File.exist?(@chef_config[:client_d_dir]) + root = Pathname(@chef_config[:client_d_dir]) + root.find do |f| + relative = f.relative_path_from(root) + if f != root + file_on_node = "#{bootstrap_directory}/client.d/#{relative}".gsub("/","\\") + if f.directory? + content << "mkdir #{file_on_node}\n" + else + content << "> #{file_on_node} (\n" + + escape_and_echo(IO.read(File.expand_path(f))) + "\n)\n" + end + end + end + end + content + end + + def fallback_install_task_command + # This command will be executed by schtasks.exe in the batch + # code below. To handle tasks that contain arguments that + # need to be double quoted, schtasks allows the use of single + # quotes that will later be converted to double quotes + command = install_command('\'') + <<~EOH + @set MSIERRORCODE=!ERRORLEVEL! + @if ERRORLEVEL 1 ( + @echo WARNING: Failed to install Chef Client MSI package in remote context with status code !MSIERRORCODE!. + @echo WARNING: This may be due to a defect in operating system update KB2918614: http://support.microsoft.com/kb/2918614 + @set OLDLOGLOCATION="%CHEF_CLIENT_MSI_LOG_PATH%-fail.log" + @move "%CHEF_CLIENT_MSI_LOG_PATH%" "!OLDLOGLOCATION!" > NUL + @echo WARNING: Saving installation log of failure at !OLDLOGLOCATION! + @echo WARNING: Retrying installation with local context... + @schtasks /create /f /sc once /st 00:00:00 /tn chefclientbootstraptask /ru SYSTEM /rl HIGHEST /tr \"cmd /c #{command} & sleep 2 & waitfor /s %computername% /si chefclientinstalldone\" + + @if ERRORLEVEL 1 ( + @echo ERROR: Failed to create Chef Client installation scheduled task with status code !ERRORLEVEL! > "&2" + ) else ( + @echo Successfully created scheduled task to install Chef Client. + @schtasks /run /tn chefclientbootstraptask + @if ERRORLEVEL 1 ( + @echo ERROR: Failed to execut Chef Client installation scheduled task with status code !ERRORLEVEL!. > "&2" + ) else ( + @echo Successfully started Chef Client installation scheduled task. + @echo Waiting for installation to complete -- this may take a few minutes... + waitfor chefclientinstalldone /t 600 + if ERRORLEVEL 1 ( + @echo ERROR: Timed out waiting for Chef Client package to install + ) else ( + @echo Finished waiting for Chef Client package to install. + ) + @schtasks /delete /f /tn chefclientbootstraptask > NUL + ) + ) + ) else ( + @echo Successfully installed Chef Client package. + ) + EOH + end + end + end + end +end -- cgit v1.2.1