diff --git a/lib/puppet/functions/ssh/ssh_keygen.rb b/lib/puppet/functions/ssh/ssh_keygen.rb new file mode 100644 index 0000000..05fc735 --- /dev/null +++ b/lib/puppet/functions/ssh/ssh_keygen.rb @@ -0,0 +1,289 @@ +# This is an autogenerated function, ported from the original legacy version. +# It /should work/ as is, but will not have all the benefits of the modern +# function API. You should see the function docs to learn how to add function +# signatures for type safety and to document this function using puppet-strings. +# +# https://puppet.com/docs/puppet/latest/custom_functions_ruby.html +# +# ---- original file header ---- +# Forked from htratps://github.com/fup/puppet-ssh @ 59684a8ae174 +# +# Takes a Hash of config arguments: +# Required parameters: +# :name (the name of the key - e.g 'my_ssh_key') +# :request (what type of return value is requested (public, private, auth, known) +# +# Optional parameters: +# :type (the key type - default: 'rsa') +# :dir (the subdir of /etc/puppet/ to store the key in - default: 'ssh') +# :hostkey (weither the key should be a hostkey or not. defines weither to add it to known_hosts or not) +# :hostaliases (specify aliases for the host for whom a hostkey is created (will be added to known_hosts)) +# :authkey (weither the key is an authkey or not. defines weither to add it to authorized_keys or not) +# :as_hash (weither to return authorized_keys as list of hashes (only for request authorized keys)) +# +require 'fileutils' + + + + +def get_known_hosts(fullpath) + known_hosts = "#{fullpath}/known_hosts" + return File.open(known_hosts).read +end + +def get_authorized_keys(fullpath, as_hash = false) + known_hosts = "#{fullpath}/authorized_keys" + + # short-circuit requests for authorized_keys before first keys have been created + unless File.exists?(known_hosts) + return (as_hash) ? {} : "" + end + + unless as_hash + return File.open(known_hosts).read + end + + result = {} + File.foreach(known_hosts) do |line| + next if line =~ /^#/ + next if line =~ /^$/ + + (type, key, comment) = line.split(' ') + + + result[comment] = { + 'type' => type, + 'key' => key, + 'name' => comment + } + end + + return result +end + + +class SSHKeyGen + # stores the name of the ssh key + attr_reader :name + + # cache dir + attr_reader :cache_dir + + # key attributes + attr_reader :key_type + attr_reader :key_comment + attr_reader :key_options + + attr_reader :facts + + def initialize(name, type, comment, options = {}) + @name = name + @cache_dir = options[:cache_dir] + @facts = options[:facts] + @key_type = type + @key_options = options + @key_comment = comment + end + + def generate_keypair + keyfile = filename_for(:private_key) + + unless File.exists?(keyfile) + cmdline = "/usr/bin/ssh-keygen -q -t #{key_type} -N '' -C '#{key_comment}' -f #{keyfile}" + output = %x[#{cmdline}] + if $?.exitstatus != 0 + raise Puppet::ParseError, "calling '#{cmdline}' resulted in error: #{output}" + end + + if key_options[:authkey] + add_key_to_authorized_keys(cache_dir, name, keyfile) + end + + if key_options[:hostkey] + add_to_known_hosts + end + end + end + + def add_to_known_hosts + known_hosts = "#{cache_dir}/known_hosts" + if not File.exists?(known_hosts) + File.open(known_hosts, 'w') { |f| f.write "# managed by puppet\n" } + end + + if not facts['fqdn'] + raise Puppet::ParseError, "unable to determine fqdn: please check system configuration" + end + + + hosts = "#{facts['hostname']},#{facts['fqdn']},#{facts['ipaddress']}" + unless key_options[:hostaliases].nil? or key_options[:hostaliases] == :undef + hosts = hosts + "," + key_options[:hostaliases].join(",") + end + + key = public_key() + search_string = "^.* " + Regexp.escape(key) + "$" + + lines = File.open(known_hosts).readlines + + unless File.open(known_hosts).readlines.grep(/#{search_string}/).size > 0 + line = "#{hosts} #{key}" + File.open(known_hosts, 'a') { |file| file.write(line) } + end + end + + def add_key_to_authorized_keys(fullpath, name, keyfile) + authorized_keys = "#{fullpath}/authorized_keys" + if not File.exists?(authorized_keys) + File.open(authorized_keys, 'w') { |f| f.write "# managed by puppet\n" } + end + + File.open(authorized_keys, 'a') { |file| file.write(public_key().to_s) } + end + + def filename_for(key_type = :private_key) + if key_type == :public_key + filename = name + '.pub' + elsif key_type == :certificate + filename = name + '-cert.pub' + else + filename = name + end + + return File.join(cache_dir, filename) + end + + + def keyfile_contents(key_type = :private_key) + keyfile = filename_for(key_type) + + unless File.exists?(keyfile) + generate_keypair(key_type) + end + + begin + return File.open(keyfile).read + rescue => e + raise Puppet::ParseError, "ssh_keygen(): unable to read file `#{keyfile}': #{e}" + end + end + + def private_key + @private_key ||= keyfile_contents(key_type = :private_key) + end + + def public_key + @public_key ||= keyfile_contents(key_type = :public_key) + end +end + +# ---- original file header ---- +# +# @summary +# Summarise what the function does here +# +Puppet::Functions.create_function(:'ssh::ssh_keygen') do + # @param args + # The original array of arguments. Port this to individually managed params + # to get the full benefit of the modern function API. + # + # @return [Data type] + # Describe what the function returns here + # + dispatch :default_impl do + # Call the method named 'default_impl' when this is matched + # Port this to match individual params for better type safety + repeated_param 'Any', :args + end + + + def default_impl(*args) + + unless args.first.class == Hash then + raise Puppet::ParseError, "ssh_keygen(): config argument must be a Hash" + end + + config = args.first + + config = { + 'request' => nil, + 'basedir' => Puppet[:vardir], + 'dir' => 'ssh', + + 'type' => 'rsa', + 'hostkey' => false, + 'hostaliases' => nil, + 'authkey' => false, + 'comment' => nil, + + 'as_hash' => false, + }.merge(config) + + if config['request'].nil? + raise Puppet::ParseError, "ssh_keygen(): request argument is required" + end + + request = config['request'] + if config['name'].nil? and (request != 'authorized_keys' and request != 'known_hosts') + raise Puppet::ParseError, "ssh_keygen(): name argument is required" + end + + # construct fullpath from puppet base and dir argument + fullpath = "#{config['basedir']}/#{config['dir']}" + if File.exists?(fullpath) and not File.directory?(fullpath) + raise Puppet::ParseError, "ssh_keygen(): #{fullpath} exists but is not directory" + end + unless File.exists?(fullpath) + FileUtils.mkdir_p fullpath + end + + if request == 'authorized_keys' + return get_authorized_keys(fullpath, as_hash=config['as_hash']) + end + + if request == 'known_hosts' + return get_known_hosts(fullpath) + end + + facts = Hash.new + %w{ hostname fqdn ipaddress }.each { |var| facts[var] = lookupvar(var) } + + # Let comment default to something sensible, unless the user really + # wants to set it to ''(then we don't stop him) + if config['comment'].nil? + hostname = lookupvar('hostname') + if config['hostkey'] == true + config['comment'] = hostname + elsif config['authkey'] == true + config['comment'] = "root@#{hostname}" + end + end + + keypair = SSHKeyGen.new(config['name'], config['type'], config['comment'], { + :cache_dir => fullpath, + :facts => facts, + :hostaliases => config['hostaliases'], + :authkey => config['authkey'], + :hostkey => config['hostkey'] + }) + + keypair.generate_keypair() + + # Check what mode of action is requested + begin + case request + when "public" + return keypair.public_key() + when "private" + return keypair.private_key() + when "known_hosts" + return get_known_hosts(fullpath) + when "authorized_keys" + return get_authorized_keys(fullpath, config['as_hash']) + end + rescue => e + raise Puppet::ParseError, "ssh_keygen(): unable to fulfill request '#{config['request']}': #{e}" + end + + end +end diff --git a/lib/puppet/functions/ssh/ssh_sign_certificate.rb b/lib/puppet/functions/ssh/ssh_sign_certificate.rb new file mode 100644 index 0000000..66c7cc0 --- /dev/null +++ b/lib/puppet/functions/ssh/ssh_sign_certificate.rb @@ -0,0 +1,152 @@ +# This is an autogenerated function, ported from the original legacy version. +# It /should work/ as is, but will not have all the benefits of the modern +# function API. You should see the function docs to learn how to add function +# signatures for type safety and to document this function using puppet-strings. +# +# https://puppet.com/docs/puppet/latest/custom_functions_ruby.html +# +# ---- original file header ---- + +require 'fileutils' + +# ---- original file header ---- +# +# @summary +# This function allows generation and puppetmaster-side caching of SSH certificates, +# for user certificates (default) and host certificates. +# +# Beneath the required parameters (see below) it can take a hash with some options that +# refer to ssh-keygen arguments. The following shows the supported parameters +# and a brief description. For further information see the ssh-keygen manpage. +# +# - validity: How long should the certificate be valid (-V) +# - serial_number: What to use as serial number (-z) +# - host_certifcate: Boolean to indicate that you want to generate a host certificate (-h) +# - principals: Array of principals set on the key (-n) +# - cert_options: Array of options to set on the certifiate (e.g. no-tty and alike, refers to -O) +# +# @param [String] signkey_file The path (on the puppet master) to a ssh private key used as CA. +# @param [String] certificate_id An id for the ssh certificate to generate (-I argument to ssh-keygen) +# @param [String] public_key The public key which shall be signed by the CA. +# @param [Hash] additional options +# @return [String] the signed certificate +# @example Example usage: +# $public_key = $::sshrsakey +# $certificate = ssh_sign_certificate('/etc/puppet/id_ca', $::fqdn, $public_key, { host_certificate: true } ) +# +# file { '/etc/ssh/ssh_host_rsa_key-cert.pub': +# ensure => present, +# content => $certificate +# } +# +# +# +Puppet::Functions.create_function(:'ssh::ssh_sign_certificate') do + # @param args + # The original array of arguments. Port this to individually managed params + # to get the full benefit of the modern function API. + # + # @return [Data type] + # Describe what the function returns here + # + dispatch :default_impl do + # Call the method named 'default_impl' when this is matched + # Port this to match individual params for better type safety + repeated_param 'Any', :args + end + + + def default_impl(*args) + + raise ArgumentError, ("ssh_sign_certificate(): wrong number of arguments (#{args.length}; must be >= 3)") if args.length < 3 + + signkey_file = args[0] + certificate_id = args[1] + public_key = args[2] + options = args[3] || {} + fqdn = lookupvar('fqdn') + vardir = Puppet[:vardir] + cache_dir = File.join(vardir, 'ssh', 'certificates', fqdn) + + non_keygen_args = %w{ cache_dir } + valid_options = { + 'validity' => ['-V', '%s'], + 'serial_number' => ['-z', '%s'], + 'host_certificate' => '-h', + 'principals' => proc { |options| [ "-n", options.join(",") ] }, + 'cert_options' => proc { |options| options.collect { |opt| ['-O', opt] } }, + 'cache_dir' => proc { |option| cache_dir = option } + } + + keygen_extra_args = [] + options.each do |name, values| + if valid_options.has_key?(name) + argspec = valid_options[name] + else + raise ArgumentError, ("ssh_sign_certificate(): unknown option `#{name}'") + end + + if argspec.kind_of?(Array) + extra_args = argspec[0] << sprintf(argspec[1], values) + elsif argspec.kind_of?(String) + extra_args = options[name] ? argspec : nil + elsif argspec.kind_of?(Proc) + extra_args = argspec.call(values) + end + + unless non_keygen_args.include?(name) and not extra_args.nil? + keygen_extra_args << extra_args + end + end + + unless File.exists?(signkey_file) and File.owned?(signkey_file) + raise ArgumentError, ("ssh_sign_certificate(): first argument (#{signkey_file}) must point to an existing key owned by us (#{Puppet[:user]})") + end + + unless certificate_id.kind_of?(String) and certificate_id + raise ArgumentError, ("ssh_sign_certificate(): second argument (#{certificate_id}) needs to be a certificate id") + end + + unless public_key.kind_of?(String) and public_key.start_with?('ssh-') + raise ArgumentError, ("ssh_sign_certificate(): expected public key as string argument, got: #{public_key}") + end + + + # used for caching generated certificates + FileUtils.mkdir_p(cache_dir) unless File.directory?(cache_dir) + + cache_file = File.join(cache_dir, fqdn + "_" + certificate_id) + debug "ssh_sign_certificate(): generated certificate will be cached in #{cache_file}" + + unless File.exists?(cache_file) + Dir.mktmpdir("puppet-") do |tmpdir| + tmpdir = '/tmp' + # need to write to temporary file, so that ssh-keygen can access it + public_key_temp_file = File.join(tmpdir, "result.pub") + # keep in mind that the directory of the files must have the same basedirectory + # as ssh-keygen does not allow specifying the path of the created certificate + certificate_temp_file = File.join(tmpdir, "result-cert.pub") + + File.open(public_key_temp_file, "w") do |file| + file.write(public_key) + end + + keygen_command = [ 'ssh-keygen', '-q', '-s', signkey_file, '-I', certificate_id, keygen_extra_args, public_key_temp_file] + keygen_command.flatten! + cmdline = keygen_command.join(" ") + + + debug "Executing #{cmdline}" + output = %x[#{cmdline} 2>&1] + if $?.exitstatus != 0 + raise Puppet::ParseError, "calling '#{cmdline}' resulted in error: #{output}" + end + + FileUtils.cp(certificate_temp_file, cache_file) + end + end + + return File.read(cache_file) + + end +end diff --git a/spec/functions/ssh_ssh_keygen_spec.rb b/spec/functions/ssh_ssh_keygen_spec.rb new file mode 100644 index 0000000..4f26013 --- /dev/null +++ b/spec/functions/ssh_ssh_keygen_spec.rb @@ -0,0 +1,41 @@ +require 'spec_helper' + +describe 'ssh::ssh_keygen' do + # without knowing details about the implementation, this is the only test + # case that we can autogenerate. You should add more examples below! + it { is_expected.not_to eq(nil) } + +################################# +# Below are some example test cases. You may uncomment and modify them to match +# your needs. Notice that they all expect the base error class of `StandardError`. +# This is because the autogenerated function uses an untyped array for parameters +# and relies on your implementation to do the validation. As you convert your +# function to proper dispatches and typed signatures, you should change the +# expected error of the argument validation examples to `ArgumentError`. +# +# Other error types you might encounter include +# +# * StandardError +# * ArgumentError +# * Puppet::ParseError +# +# Read more about writing function unit tests at https://rspec-puppet.com/documentation/functions/ +# +# it 'raises an error if called with no argument' do +# is_expected.to run.with_params.and_raise_error(StandardError) +# end +# +# it 'raises an error if there is more than 1 arguments' do +# is_expected.to run.with_params({ 'foo' => 1 }, 'bar' => 2).and_raise_error(StandardError) +# end +# +# it 'raises an error if argument is not the proper type' do +# is_expected.to run.with_params('foo').and_raise_error(StandardError) +# end +# +# it 'returns the proper output' do +# is_expected.to run.with_params(123).and_return('the expected output') +# end +################################# + +end diff --git a/spec/functions/ssh_ssh_sign_certificate_spec.rb b/spec/functions/ssh_ssh_sign_certificate_spec.rb new file mode 100644 index 0000000..35f8134 --- /dev/null +++ b/spec/functions/ssh_ssh_sign_certificate_spec.rb @@ -0,0 +1,41 @@ +require 'spec_helper' + +describe 'ssh::ssh_sign_certificate' do + # without knowing details about the implementation, this is the only test + # case that we can autogenerate. You should add more examples below! + it { is_expected.not_to eq(nil) } + +################################# +# Below are some example test cases. You may uncomment and modify them to match +# your needs. Notice that they all expect the base error class of `StandardError`. +# This is because the autogenerated function uses an untyped array for parameters +# and relies on your implementation to do the validation. As you convert your +# function to proper dispatches and typed signatures, you should change the +# expected error of the argument validation examples to `ArgumentError`. +# +# Other error types you might encounter include +# +# * StandardError +# * ArgumentError +# * Puppet::ParseError +# +# Read more about writing function unit tests at https://rspec-puppet.com/documentation/functions/ +# +# it 'raises an error if called with no argument' do +# is_expected.to run.with_params.and_raise_error(StandardError) +# end +# +# it 'raises an error if there is more than 1 arguments' do +# is_expected.to run.with_params({ 'foo' => 1 }, 'bar' => 2).and_raise_error(StandardError) +# end +# +# it 'raises an error if argument is not the proper type' do +# is_expected.to run.with_params('foo').and_raise_error(StandardError) +# end +# +# it 'returns the proper output' do +# is_expected.to run.with_params(123).and_return('the expected output') +# end +################################# + +end