#
# Author:: Adam Jacob (<adam@chef.io>)
# Copyright:: Copyright (c) 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_relative "../knife"
class Chef
class Knife
class Ssh < Knife
deps do
require "chef/mixin/shell_out" unless defined?(Chef::Mixin::ShellOut)
require "net/ssh" unless defined?(Net::SSH)
require "net/ssh/multi"
require "readline"
require "chef/exceptions" unless defined?(Chef::Exceptions)
require "chef/search/query" unless defined?(Chef::Search::Query)
require "chef-config/path_helper" unless defined?(ChefConfig::PathHelper)
include Chef::Mixin::ShellOut
end
attr_writer :password
banner "knife ssh QUERY COMMAND (options)"
option :concurrency,
short: "-C NUM",
long: "--concurrency NUM",
description: "The number of concurrent connections.",
default: nil,
proc: lambda { |o| o.to_i }
option :ssh_attribute,
short: "-a ATTR",
long: "--attribute ATTR",
description: "The attribute to use for opening the connection - default depends on the context."
option :manual,
short: "-m",
long: "--manual-list",
boolean: true,
description: "QUERY is a space separated list of servers.",
default: false
option :prefix_attribute,
long: "--prefix-attribute ATTR",
description: "The attribute to use for prefixing the output - default depends on the context."
option :ssh_user,
short: "-x USERNAME",
long: "--ssh-user USERNAME",
description: "The ssh username."
option :ssh_password,
short: "-P [PASSWORD]",
long: "--ssh-password [PASSWORD]",
description: "The ssh password - will prompt if flag is specified but no password is given.",
# default to a value that can not be a password (boolean)
# so we can effectively test if this parameter was specified
# without a value
default: false
option :ssh_port,
short: "-p PORT",
long: "--ssh-port PORT",
description: "The ssh port.",
proc: Proc.new { |key| key.strip }
option :ssh_timeout,
short: "-t SECONDS",
long: "--ssh-timeout SECONDS",
description: "The ssh connection timeout.",
proc: Proc.new { |key| key.strip.to_i },
default: 120
option :ssh_gateway,
short: "-G GATEWAY",
long: "--ssh-gateway GATEWAY",
description: "The ssh gateway.",
proc: Proc.new { |key| key.strip }
option :ssh_gateway_identity,
long: "--ssh-gateway-identity SSH_GATEWAY_IDENTITY",
description: "The SSH identity file used for gateway authentication."
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 :host_key_verify,
long: "--[no-]host-key-verify",
description: "Verify host key, enabled by default.",
boolean: true,
default: true
option :on_error,
short: "-e",
long: "--exit-on-error",
description: "Immediately exit if an error is encountered.",
boolean: true,
default: false
option :duplicated_fqdns,
long: "--duplicated-fqdns",
description: "Behavior if FQDNs are duplicated, ignored by default.",
proc: Proc.new { |key| key.strip.to_sym },
default: :ignore
option :tmux_split,
long: "--tmux-split",
description: "Split tmux window.",
boolean: true,
default: false
option :pty,
long: "--[no-]pty",
description: "Request a PTY, enabled by default.",
boolean: true,
default: true
option :require_pty,
long: "--[no-]require-pty",
description: "Raise exception if a PTY cannot be acquired, disabled by default.",
boolean: true,
default: false
def session
ssh_error_handler = Proc.new do |server|
if config[:on_error]
# Net::SSH::Multi magic to force exception to be re-raised.
throw :go, :raise
else
ui.warn "Failed to connect to #{server.host} -- #{$!.class.name}: #{$!.message}"
$!.backtrace.each { |l| Chef::Log.debug(l) }
end
end
@session ||= Net::SSH::Multi.start(concurrent_connections: config[:concurrency], on_error: ssh_error_handler)
end
def configure_gateway
if config[:ssh_gateway]
gw_host, gw_user = config[:ssh_gateway].split("@").reverse
gw_host, gw_port = gw_host.split(":")
gw_opts = session_options(gw_host, gw_port, gw_user, gateway: true)
user = gw_opts.delete(:user)
begin
# Try to connect with a key.
session.via(gw_host, user, gw_opts)
rescue Net::SSH::AuthenticationFailed
prompt = "Enter the password for #{user}@#{gw_host}: "
gw_opts[:password] = prompt_for_password(prompt)
# Try again with a password.
session.via(gw_host, user, gw_opts)
end
end
end
def configure_session
list = config[:manual] ? @name_args[0].split(" ") : search_nodes
if list.length == 0
if @search_count == 0
ui.fatal("No nodes returned from search")
else
ui.fatal("#{@search_count} #{@search_count > 1 ? "nodes" : "node"} found, " +
"but does not have the required attribute to establish the connection. " +
"Try setting another attribute to open the connection using --attribute.")
end
exit 10
end
if %i{warn fatal}.include?(config[:duplicated_fqdns])
fqdns = list.map { |v| v[0] }
if fqdns.count != fqdns.uniq.count
duplicated_fqdns = fqdns.uniq
ui.send(config[:duplicated_fqdns],
"SSH #{duplicated_fqdns.count > 1 ? "nodes are" : "node is"} " +
"duplicated: #{duplicated_fqdns.join(",")}")
exit 10 if config[:duplicated_fqdns] == :fatal
end
end
session_from_list(list)
end
def get_prefix_attribute(item)
# Order of precedence for prefix
# 1) config value (cli or knife config)
# 2) nil
msg = "Using node attribute '%s' as the prefix: %s"
if item["prefix"]
Chef::Log.debug(sprintf(msg, config[:prefix_attribute], item["prefix"]))
item["prefix"]
else
nil
end
end
def get_ssh_attribute(item)
# Order of precedence for ssh target
# 1) config value (cli or knife config)
# 2) cloud attribute
# 3) fqdn
msg = "Using node attribute '%s' as the ssh target: %s"
if item["target"]
Chef::Log.debug(sprintf(msg, config[:ssh_attribute], item["target"]))
item["target"]
elsif !item.dig("cloud", "public_hostname").to_s.empty?
Chef::Log.debug(sprintf(msg, "cloud.public_hostname", item["cloud"]["public_hostname"]))
item["cloud"]["public_hostname"]
else
Chef::Log.debug(sprintf(msg, "fqdn", item["fqdn"]))
item["fqdn"]
end
end
def search_nodes
list = []
query = Chef::Search::Query.new
required_attributes = { fqdn: ["fqdn"], cloud: ["cloud"] }
separator = ui.presenter.attribute_field_separator
if config[:prefix_attribute]
required_attributes[:prefix] = config[:prefix_attribute].split(separator)
end
if config[:ssh_attribute]
required_attributes[:target] = config[:ssh_attribute].split(separator)
end
@search_count = 0
query.search(:node, @name_args[0], filter_result: required_attributes, fuzz: true) do |item|
@search_count += 1
# we should skip the loop to next iteration if the item
# returned by the search is nil
next if item.nil?
# next if we couldn't find the specified attribute in the
# returned node object
host = get_ssh_attribute(item)
next if host.nil?
prefix = get_prefix_attribute(item)
ssh_port = item.dig("cloud", "public_ssh_port")
srv = [host, ssh_port, prefix]
list.push(srv)
end
list
end
# Net::SSH session options hash for global options. These should be
# options that will apply to the gateway connection in addition to the
# main one.
#
# @since 12.5.0
# @param host [String] Hostname for this session.
# @param port [String] SSH port for this session.
# @param user [String] Optional username for this session.
# @param gateway [Boolean] Flag: host or gateway key
# @return [Hash<Symbol, Object>]
def session_options(host, port, user = nil, gateway: false)
ssh_config = Net::SSH.configuration_for(host, true)
{}.tap do |opts|
opts[:user] = user || config[:ssh_user] || ssh_config[:user]
if !gateway && config[:ssh_identity_file]
opts[:keys] = File.expand_path(config[:ssh_identity_file])
opts[:keys_only] = true
elsif gateway && config[:ssh_gateway_identity]
opts[:keys] = File.expand_path(config[:ssh_gateway_identity])
opts[:keys_only] = true
elsif config[:ssh_password]
opts[:password] = config[:ssh_password]
end
# Don't set the keys to nil if we don't have them.
forward_agent = config[:forward_agent] || ssh_config[:forward_agent]
opts[:forward_agent] = forward_agent unless forward_agent.nil?
port ||= ssh_config[:port]
opts[:port] = port unless port.nil?
opts[:logger] = Chef::Log.with_child(subsystem: "net/ssh") if Chef::Log.level == :trace
unless config[:host_key_verify]
opts[:verify_host_key] = :never
opts[:user_known_hosts_file] = "/dev/null"
end
if ssh_config[:keepalive]
opts[:keepalive] = true
opts[:keepalive_interval] = ssh_config[:keepalive_interval]
end
# maintain support for legacy key types / ciphers / key exchange algorithms.
# most importantly this adds back support for DSS host keys
# See https://github.com/net-ssh/net-ssh/pull/709
opts[:append_all_supported_algorithms] = true
end
end
def session_from_list(list)
list.each do |item|
host, ssh_port, prefix = item
prefix = host unless prefix
Chef::Log.debug("Adding #{host}")
session_opts = session_options(host, ssh_port, gateway: false)
# Handle port overrides for the main connection.
session_opts[:port] = config[:ssh_port] if config[:ssh_port]
# Handle connection timeout
session_opts[:timeout] = config[:ssh_timeout] if config[:ssh_timeout]
# Handle session prefix
session_opts[:properties] = { prefix: prefix }
# Create the hostspec.
hostspec = session_opts[:user] ? "#{session_opts.delete(:user)}@#{host}" : host
# Connect a new session on the multi.
session.use(hostspec, session_opts)
@longest = prefix.length if prefix.length > @longest
end
session
end
def fixup_sudo(command)
command.sub(/^sudo/, "sudo -p 'knife sudo password: '")
end
def print_data(host, data)
@buffers ||= {}
if leftover = @buffers[host]
@buffers[host] = nil
print_data(host, leftover + data)
else
if newline_index = data.index("\n")
line = data.slice!(0...newline_index)
data.slice!(0)
print_line(host, line)
print_data(host, data)
else
@buffers[host] = data
end
end
end
def print_line(host, data)
padding = @longest - host.length
str = ui.color(host, :cyan) + (" " * (padding + 1)) + data
ui.msg(str)
end
# @param command [String] the command to run
# @param session_list [???] list of sessions, one per node
#
def ssh_command(command, session_list = session)
stderr = ""
exit_status = 0
command = fixup_sudo(command)
command.force_encoding("binary") if command.respond_to?(:force_encoding)
session_list.open_channel do |chan|
if config[:on_error] && exit_status != 0
chan.close
else
if config[:pty]
chan.request_pty do |ch, success|
unless success
ui.warn("Failed to obtain a PTY from #{ch.connection.host}")
raise ArgumentError, "Request for PTY failed" if config[:require_pty]
end
end
end
chan.exec command do |ch, success|
raise ArgumentError, "Cannot execute #{command}" unless success
ch.on_data do |ichannel, data|
print_data(ichannel.connection[:prefix], data)
if /^knife sudo password: /.match?(data)
print_data(ichannel.connection[:prefix], "\n")
ichannel.send_data("#{get_password}\n")
end
end
ch.on_extended_data do |_, _type, data|
raise ArgumentError, "No PTY present. If a PTY is required use --require-pty" if data.eql?("sudo: no tty present and no askpass program specified\n")
stderr += data
end
ch.on_request "exit-status" do |ichannel, data|
exit_status = [exit_status, data.read_long].max
end
end
end
end
session.loop
exit_status
ensure
session_list.close
end
def get_password
@password ||= prompt_for_password
end
def prompt_for_password(prompt = "Enter your password: ")
ui.ask(prompt, echo: false)
end
# Present the prompt and read a single line from the console. It also
# detects ^D and returns "exit" in that case. Adds the input to the
# history, unless the input is empty. Loops repeatedly until a non-empty
# line is input.
def read_line
loop do
command = reader.readline("#{ui.color("knife-ssh>", :bold)} ", true)
if command.nil?
command = "exit"
puts(command)
else
command.strip!
end
unless command.empty?
return command
end
end
end
def reader
Readline
end
def interactive
puts "Connected to #{ui.list(session.servers_for.collect { |s| ui.color(s.host, :cyan) }, :inline, " and ")}"
puts
puts "To run a command on a list of servers, do:"
puts " on SERVER1 SERVER2 SERVER3; COMMAND"
puts " Example: on latte foamy; echo foobar"
puts
puts "To exit interactive mode, use 'quit!'"
puts
loop do
command = read_line
case command
when "quit!"
puts "Bye!"
break
when /^on (.+?); (.+)$/
raw_list = $1.split(" ")
server_list = []
session.servers.each do |session_server|
server_list << session_server if raw_list.include?(session_server.host)
end
command = $2
ssh_command(command, session.on(*server_list))
else
ssh_command(command)
end
end
end
def screen
tf = Tempfile.new("knife-ssh-screen")
ChefConfig::PathHelper.home(".screenrc") do |screenrc_path|
if File.exist? screenrc_path
tf.puts("source #{screenrc_path}")
end
end
tf.puts("caption always '%-Lw%{= BW}%50>%n%f* %t%{-}%+Lw%<'")
tf.puts("hardstatus alwayslastline 'knife ssh #{@name_args[0]}'")
window = 0
session.servers_for.each do |server|
tf.print("screen -t \"#{server.host}\" #{window} ssh ")
tf.print("-i #{config[:ssh_identity_file]} ") if config[:ssh_identity_file]
server.user ? tf.puts("#{server.user}@#{server.host}") : tf.puts(server.host)
window += 1
end
tf.close
exec("screen -c #{tf.path}")
end
def tmux
ssh_dest = lambda do |server|
identity = "-i #{config[:ssh_identity_file]} " if config[:ssh_identity_file]
prefix = server.user ? "#{server.user}@" : ""
"'ssh #{identity}#{prefix}#{server.host}'"
end
new_window_cmds = lambda do
if session.servers_for.size > 1
[""] + session.servers_for[1..].map do |server|
if config[:tmux_split]
"split-window #{ssh_dest.call(server)}; tmux select-layout tiled"
else
"new-window -a -n '#{server.host}' #{ssh_dest.call(server)}"
end
end
else
[]
end.join(" \\; ")
end
tmux_name = "'knife ssh #{@name_args[0].tr(":.", "=-")}'"
begin
server = session.servers_for.first
cmd = ["tmux new-session -d -s #{tmux_name}",
"-n '#{server.host}'", ssh_dest.call(server),
new_window_cmds.call].join(" ")
shell_out!(cmd)
exec("tmux attach-session -t #{tmux_name}")
rescue Chef::Exceptions::Exec
end
end
def macterm
begin
require "appscript" unless defined?(Appscript)
rescue LoadError
STDERR.puts "You need the rb-appscript gem to use knife ssh macterm. `(sudo) gem install rb-appscript` to install"
raise
end
Appscript.app("/Applications/Utilities/Terminal.app").windows.first.activate
Appscript.app("System Events").application_processes["Terminal.app"].keystroke("n", using: :command_down)
term = Appscript.app("Terminal")
window = term.windows.first.get
(session.servers_for.size - 1).times do |i|
window.activate
Appscript.app("System Events").application_processes["Terminal.app"].keystroke("t", using: :command_down)
end
session.servers_for.each_with_index do |server, tab_number|
cmd = "unset PROMPT_COMMAND; echo -e \"\\033]0;#{server.host}\\007\"; ssh #{server.user ? "#{server.user}@#{server.host}" : server.host}"
Appscript.app("Terminal").do_script(cmd, in: window.tabs[tab_number + 1].get)
end
end
def cssh
cssh_cmd = nil
%w{csshX cssh}.each do |cmd|
# Unix and Mac only
cssh_cmd = shell_out!("which #{cmd}").stdout.strip
break
rescue Mixlib::ShellOut::ShellCommandFailed
end
raise Chef::Exceptions::Exec, "no command found for cssh" unless cssh_cmd
# pass in the consolidated identity file option to cssh(X)
if config[:ssh_identity_file]
cssh_cmd << " --ssh_args '-i #{File.expand_path(config[:ssh_identity_file])}'"
end
session.servers_for.each do |server|
cssh_cmd << " #{server.user ? "#{server.user}@#{server.host}" : server.host}"
end
Chef::Log.debug("Starting cssh session with command: #{cssh_cmd}")
exec(cssh_cmd)
end
def get_stripped_unfrozen_value(value)
return nil unless value
value.strip
end
def configure_user
config[:ssh_user] = get_stripped_unfrozen_value(config[:ssh_user] ||
Chef::Config[:knife][:ssh_user])
end
def configure_password
if config.key?(:ssh_password) && config[:ssh_password].nil?
# if we have an actual nil that means someone called "--ssh-password" with no value, so we prompt for a password
config[:ssh_password] = get_password
else
# the false default of ssh_password results in a nil here
config[:ssh_password] = get_stripped_unfrozen_value(config[:ssh_password])
end
end
def configure_ssh_identity_file
config[:ssh_identity_file] = get_stripped_unfrozen_value(config[:ssh_identity_file])
end
def configure_ssh_gateway_identity
config[:ssh_gateway_identity] = get_stripped_unfrozen_value(config[:ssh_gateway_identity])
end
def run
@longest = 0
if @name_args.length < 1
show_usage
ui.fatal("You must specify the SEARCH QUERY.")
exit(1)
end
configure_user
configure_password
@password = config[:ssh_password] if config[:ssh_password]
# If a password was not given, check for SSH identity file.
unless @password
configure_ssh_identity_file
configure_ssh_gateway_identity
end
configure_gateway
configure_session
exit_status =
case @name_args[1]
when "interactive"
interactive
when "screen"
screen
when "tmux"
tmux
when "macterm"
macterm
when "cssh"
cssh
else
ssh_command(@name_args[1..].join(" "))
end
session.close
if exit_status && exit_status != 0
exit exit_status
else
exit_status
end
end
private :search_nodes
end
end
end