diff --git a/acceptance/tests/helpful_error_message_when_hostname_not_match_server_certificate.rb b/acceptance/tests/helpful_error_message_when_hostname_not_match_server_certificate.rb index c3b5b6795..6b0566e01 100644 --- a/acceptance/tests/helpful_error_message_when_hostname_not_match_server_certificate.rb +++ b/acceptance/tests/helpful_error_message_when_hostname_not_match_server_certificate.rb @@ -1,12 +1,12 @@ test_name "generate a helpful error message when hostname doesn't match server certificate" step "Clear any existing SSL directories" -on(hosts, "rm -r #{config['puppetpath']}/ssl") +on(hosts, "rm -rf #{config['puppetpath']}/ssl") # Start the master with a certname not matching its hostname -with_master_running_on(master, "--certname foobar_not_my_hostname --certdnsnames one_cert:two_cert:red_cert:blue_cert --autosign true") do +with_master_running_on(master, "--certname foobar_not_my_hostname --dns_alt_names one_cert,two_cert,red_cert,blue_cert --autosign true") do run_agent_on(agents, "--no-daemonize --verbose --onetime --server #{master}", :acceptable_exit_codes => (1..255)) do - msg = "Server hostname '#{master}' did not match server certificate; expected one of foobar_not_my_hostname, one_cert, two_cert, red_cert, blue_cert" + msg = "Server hostname '#{master}' did not match server certificate; expected one of foobar_not_my_hostname, DNS:blue_cert, DNS:foobar_not_my_hostname, DNS:one_cert, DNS:red_cert, DNS:two_cert" assert_match(msg, stdout) end end diff --git a/acceptance/tests/resource/cron/should_remove_cron.rb b/acceptance/tests/resource/cron/should_remove_cron.rb index d6b8822d6..78afde4c3 100755 --- a/acceptance/tests/resource/cron/should_remove_cron.rb +++ b/acceptance/tests/resource/cron/should_remove_cron.rb @@ -1,32 +1,32 @@ test_name "puppet should remove a crontab entry as expected" tmpuser = "pl#{rand(999999).to_i}" tmpfile = "/tmp/cron-test-#{Time.new.to_i}" create_user = "user { '#{tmpuser}': ensure => present, managehome => false }" delete_user = "user { '#{tmpuser}': ensure => absent, managehome => false }" agents.each do |host| step "ensure the user exist via puppet" apply_manifest_on host, create_user step "create the existing job by hand..." run_cron_on(host,:add,tmpuser,"* * * * * /bin/true") step "apply the resource on the host using puppet resource" on(host, puppet_resource("cron", "crontest", "user=#{tmpuser}", "command=/bin/true", "ensure=absent")) do assert_match(/crontest\D+ensure:\s+removed/, stdout, "Didn't remove crobtab entry for #{tmpuser} on #{host}") end step "verify that crontab -l contains what you expected" run_cron_on(host, :list, tmpuser) do - assert_match(/\/bin\/true/, stdout, "Error: Found entry for #{tmpuser} on #{host}") + assert_no_match(/\/bin\/true/, stdout, "Error: Found entry for #{tmpuser} on #{host}") end step "remove the crontab file for that user" run_cron_on(host, :remove, tmpuser) step "remove the user from the system" apply_manifest_on host, delete_user end diff --git a/acceptance/tests/resource/cron/should_remove_matching.rb b/acceptance/tests/resource/cron/should_remove_matching.rb index 59042c7a8..bbfea349e 100755 --- a/acceptance/tests/resource/cron/should_remove_matching.rb +++ b/acceptance/tests/resource/cron/should_remove_matching.rb @@ -1,35 +1,35 @@ test_name "puppet should remove a crontab entry based on command matching" tmpuser = "pl#{rand(999999).to_i}" tmpfile = "/tmp/cron-test-#{Time.new.to_i}" cron = '# Puppet Name: crontest\n* * * * * /bin/true\n1 1 1 1 1 /bin/true\n' create_user = "user { '#{tmpuser}': ensure => present, managehome => false }" delete_user = "user { '#{tmpuser}': ensure => absent, managehome => false }" agents.each do |host| step "ensure the user exist via puppet" apply_manifest_on host, create_user step "create the existing job by hand..." run_cron_on(host,:add,tmpuser,"* * * * * /bin/true") step "Remove cron resource" on(host, puppet_resource("cron", "bogus", "user=#{tmpuser}", "command=/bin/true", "ensure=absent")) do assert_match(/bogus\D+ensure: removed/, stdout, "Removing cron entry failed for #{tmpuser} on #{host}") end step "verify that crontab -l contains what you expected" run_cron_on(host,:list,tmpuser) do count = stdout.scan("/bin/true").length - fail_test "found /bin/true the wrong number of times (#{count})" unless count == 1 + fail_test "found /bin/true the wrong number of times (#{count})" unless count == 0 end step "remove the crontab file for that user" run_cron_on(host,:remove,tmpuser) step "remove the user from the system" apply_manifest_on host, delete_user end diff --git a/acceptance/tests/ticket_3360_allow_duplicate_csr_with_option_set.rb b/acceptance/tests/ticket_3360_allow_duplicate_csr_with_option_set.rb index a34a3e718..edd52b46c 100644 --- a/acceptance/tests/ticket_3360_allow_duplicate_csr_with_option_set.rb +++ b/acceptance/tests/ticket_3360_allow_duplicate_csr_with_option_set.rb @@ -1,48 +1,48 @@ test_name "#3360: Allow duplicate CSR when allow_duplicate_certs is on" agent_hostnames = agents.map {|a| a.to_s} step "Remove existing SSL directory for hosts" on hosts, "rm -r #{config['puppetpath']}/ssl" -with_master_running_on master, "--allow_duplicate_certs --certdnsnames=\"puppet:$(hostname -s):$(hostname -f)\" --verbose --noop" do +with_master_running_on master, "--allow_duplicate_certs --dns_alt_names=\"puppet,$(hostname -s),$(hostname -f)\" --verbose --noop" do step "Generate a certificate request for the agent" on agents, "puppet certificate generate `hostname -f` --ca-location remote --server #{master}" step "Collect the original certs" on master, puppet_cert("--sign --all") original_certs = on master, puppet_cert("--list --all") old_certs = {} original_certs.stdout.each_line do |line| if line =~ /^\+ (\S+) \((.+)\)$/ old_certs[$1] = $2 puts "old cert: #{$1} #{$2}" end end step "Make another request with the same certname" on agents, "puppet certificate generate `hostname -f` --ca-location remote --server #{master}" step "Collect the new certs" on master, puppet_cert("--sign --all") new_cert_list = on master, puppet_cert("--list --all") new_certs = {} new_cert_list.stdout.each_line do |line| if line =~ /^\+ (\S+) \((.+)\)$/ new_certs[$1] = $2 puts "new cert: #{$1} #{$2}" end end step "Verify the certs have changed" # using the agent name as the key may cause errors; # agent name from cfg file is likely to have short name # where certs might be signed with long names. old_certs.each_key { |key| next if key.include? master # skip the masters cert, only care about agents assert_not_equal(old_certs[key], new_certs[key], "Expected #{key} to have a changed key") } end diff --git a/lib/puppet/provider/nameservice/directoryservice.rb b/lib/puppet/provider/nameservice/directoryservice.rb index 35ac8d76a..1cff63eb7 100644 --- a/lib/puppet/provider/nameservice/directoryservice.rb +++ b/lib/puppet/provider/nameservice/directoryservice.rb @@ -1,537 +1,639 @@ require 'puppet' require 'puppet/provider/nameservice' require 'facter/util/plist' require 'cgi' - +require 'fileutils' class Puppet::Provider::NameService class DirectoryService < Puppet::Provider::NameService # JJM: Dive into the singleton_class class << self # JJM: This allows us to pass information when calling # Puppet::Type.type # e.g. Puppet::Type.type(:user).provide :directoryservice, :ds_path => "Users" # This is referenced in the get_ds_path class method attr_writer :ds_path attr_writer :macosx_version_major end initvars commands :dscl => "/usr/bin/dscl" commands :dseditgroup => "/usr/sbin/dseditgroup" commands :sw_vers => "/usr/bin/sw_vers" + commands :plutil => '/usr/bin/plutil' confine :operatingsystem => :darwin defaultfor :operatingsystem => :darwin # JJM 2007-07-25: This map is used to map NameService attributes to their # corresponding DirectoryService attribute names. # See: http://images.apple.com/server/docs.Open_Directory_v10.4.pdf # JJM: Note, this is de-coupled from the Puppet::Type, and must # be actively maintained. There may also be collisions with different # types (Users, Groups, Mounts, Hosts, etc...) @@ds_to_ns_attribute_map = { 'RecordName' => :name, 'PrimaryGroupID' => :gid, 'NFSHomeDirectory' => :home, 'UserShell' => :shell, 'UniqueID' => :uid, 'RealName' => :comment, 'Password' => :password, 'GeneratedUID' => :guid, 'IPAddress' => :ip_address, 'ENetAddress' => :en_address, 'GroupMembership' => :members, } # JJM The same table as above, inverted. @@ns_to_ds_attribute_map = { :name => 'RecordName', :gid => 'PrimaryGroupID', :home => 'NFSHomeDirectory', :shell => 'UserShell', :uid => 'UniqueID', :comment => 'RealName', :password => 'Password', :guid => 'GeneratedUID', :en_address => 'ENetAddress', :ip_address => 'IPAddress', :members => 'GroupMembership', } @@password_hash_dir = "/var/db/shadow/hash" + @@users_plist_dir = '/var/db/dslocal/nodes/Default/users' + def self.instances # JJM Class method that provides an array of instance objects of this # type. # JJM: Properties are dependent on the Puppet::Type we're managine. type_property_array = [:name] + @resource_type.validproperties # Create a new instance of this Puppet::Type for each object present # on the system. list_all_present.collect do |name_string| self.new(single_report(name_string, *type_property_array)) end end def self.get_ds_path # JJM: 2007-07-24 This method dynamically returns the DS path we're concerned with. # For example, if we're working with an user type, this will be /Users # with a group type, this will be /Groups. # @ds_path is an attribute of the class itself. return @ds_path if defined?(@ds_path) # JJM: "Users" or "Groups" etc ... (Based on the Puppet::Type) # Remember this is a class method, so self.class is Class # Also, @resource_type seems to be the reference to the # Puppet::Type this class object is providing for. @resource_type.name.to_s.capitalize + "s" end def self.get_macosx_version_major return @macosx_version_major if defined?(@macosx_version_major) begin # Make sure we've loaded all of the facts Facter.loadfacts if Facter.value(:macosx_productversion_major) product_version_major = Facter.value(:macosx_productversion_major) else # TODO: remove this code chunk once we require Facter 1.5.5 or higher. Puppet.warning("DEPRECATION WARNING: Future versions of the directoryservice provider will require Facter 1.5.5 or newer.") product_version = Facter.value(:macosx_productversion) fail("Could not determine OS X version from Facter") if product_version.nil? product_version_major = product_version.scan(/(\d+)\.(\d+)./).join(".") end fail("#{product_version_major} is not supported by the directoryservice provider") if %w{10.0 10.1 10.2 10.3}.include?(product_version_major) @macosx_version_major = product_version_major return @macosx_version_major rescue Puppet::ExecutionFailure => detail fail("Could not determine OS X version: #{detail}") end end def self.list_all_present # JJM: List all objects of this Puppet::Type already present on the system. begin dscl_output = execute(get_exec_preamble("-list")) rescue Puppet::ExecutionFailure => detail fail("Could not get #{@resource_type.name} list from DirectoryService") end dscl_output.split("\n") end def self.parse_dscl_url_data(dscl_output) # we need to construct a Hash from the dscl -url output to match # that returned by the dscl -plist output for 10.5+ clients. # # Nasty assumptions: # a) no values *end* in a colon ':', only keys # b) if a line ends in a colon and the next line does start with # a space, then the second line is a value of the first. # c) (implied by (b)) keys don't start with spaces. dscl_plist = {} dscl_output.split("\n").inject([]) do |array, line| if line =~ /^\s+/ # it's a value array[-1] << line # add the value to the previous key else array << line end array end.compact dscl_output.each do |line| # This should be a 'normal' entry. key and value on one line. # We split on ': ' to deal with keys/values with a colon in them. split_array = line.split(/:\s+/) key = split_array.first value = CGI::unescape(split_array.last.strip.chomp) # We need to treat GroupMembership separately as it is currently # the only attribute we care about multiple values for, and # the values can never contain spaces (shortnames) # We also make every value an array to be consistent with the # output of dscl -plist under 10.5 if key == "GroupMembership" dscl_plist[key] = value.split(/\s/) else dscl_plist[key] = [value] end end dscl_plist end def self.parse_dscl_plist_data(dscl_output) Plist.parse_xml(dscl_output) end def self.generate_attribute_hash(input_hash, *type_properties) attribute_hash = {} input_hash.keys.each do |key| ds_attribute = key.sub("dsAttrTypeStandard:", "") next unless (@@ds_to_ns_attribute_map.keys.include?(ds_attribute) and type_properties.include? @@ds_to_ns_attribute_map[ds_attribute]) ds_value = input_hash[key] case @@ds_to_ns_attribute_map[ds_attribute] when :members ds_value = ds_value # only members uses arrays so far when :gid, :uid # OS X stores objects like uid/gid as strings. # Try casting to an integer for these cases to be # consistent with the other providers and the group type # validation begin ds_value = Integer(ds_value[0]) rescue ArgumentError ds_value = ds_value[0] end else ds_value = ds_value[0] end attribute_hash[@@ds_to_ns_attribute_map[ds_attribute]] = ds_value end # NBK: need to read the existing password here as it's not actually # stored in the user record. It is stored at a path that involves the # UUID of the user record for non-Mobile local acccounts. # Mobile Accounts are out of scope for this provider for now - attribute_hash[:password] = self.get_password(attribute_hash[:guid]) if @resource_type.validproperties.include?(:password) and Puppet.features.root? + attribute_hash[:password] = self.get_password(attribute_hash[:guid], attribute_hash[:name]) if @resource_type.validproperties.include?(:password) and Puppet.features.root? attribute_hash end def self.single_report(resource_name, *type_properties) # JJM 2007-07-24: # Given a the name of an object and a list of properties of that # object, return all property values in a hash. # # This class method returns nil if the object doesn't exist # Otherwise, it returns a hash of the object properties. all_present_str_array = list_all_present # NBK: shortcut the process if the resource is missing return nil unless all_present_str_array.include? resource_name dscl_vector = get_exec_preamble("-read", resource_name) begin dscl_output = execute(dscl_vector) rescue Puppet::ExecutionFailure => detail fail("Could not get report. command execution failed.") end # Two code paths is ugly, but until we can drop 10.4 support we don't # have a lot of choice. Ultimately this should all be done using Ruby # to access the DirectoryService APIs directly, but that's simply not # feasible for a while yet. if self.get_macosx_version_major > "10.4" dscl_plist = self.parse_dscl_plist_data(dscl_output) elsif self.get_macosx_version_major == "10.4" dscl_plist = self.parse_dscl_url_data(dscl_output) else fail("Puppet does not support OS X versions < 10.4") end self.generate_attribute_hash(dscl_plist, *type_properties) end def self.get_exec_preamble(ds_action, resource_name = nil) # JJM 2007-07-24 # DSCL commands are often repetitive and contain the same positional # arguments over and over. See http://developer.apple.com/documentation/Porting/Conceptual/PortingUnix/additionalfeatures/chapter_10_section_9.html # for an example of what I mean. # This method spits out proper DSCL commands for us. # We EXPECT name to be @resource[:name] when called from an instance object. # 10.4 doesn't support the -plist option for dscl, and 10.5 has a # different format for the -url output with objects with spaces in # their values. *sigh*. Use -url for 10.4 in the hope this can be # deprecated one day, and use -plist for 10.5 and higher. if self.get_macosx_version_major > "10.4" command_vector = [ command(:dscl), "-plist", "." ] elsif self.get_macosx_version_major == "10.4" command_vector = [ command(:dscl), "-url", "." ] else fail("Puppet does not support OS X versions < 10.4") end # JJM: The actual action to perform. See "man dscl" # Common actiosn: -create, -delete, -merge, -append, -passwd command_vector << ds_action # JJM: get_ds_path will spit back "Users" or "Groups", # etc... Depending on the Puppet::Type of our self. if resource_name command_vector << "/#{get_ds_path}/#{resource_name}" else command_vector << "/#{get_ds_path}" end # JJM: This returns most of the preamble of the command. # e.g. 'dscl / -create /Users/mccune' command_vector end def self.set_password(resource_name, guid, password_hash) - password_hash_file = "#{@@password_hash_dir}/#{guid}" - begin - File.open(password_hash_file, 'w') { |f| f.write(password_hash)} - rescue Errno::EACCES => detail - fail("Could not write to password hash file: #{detail}") + # Use Puppet::Util::Package.versioncmp() to catch the scenario where a + # version '10.10' would be < '10.7' with simple string comparison. This + # if-statement only executes if the current version is less-than 10.7 + if (Puppet::Util::Package.versioncmp(get_macosx_version_major, '10.7') == -1) + password_hash_file = "#{@@password_hash_dir}/#{guid}" + begin + File.open(password_hash_file, 'w') { |f| f.write(password_hash)} + rescue Errno::EACCES => detail + fail("Could not write to password hash file: #{detail}") + end + + # NBK: For shadow hashes, the user AuthenticationAuthority must contain a value of + # ";ShadowHash;". The LKDC in 10.5 makes this more interesting though as it + # will dynamically generate ;Kerberosv5;;username@LKDC:SHA1 attributes if + # missing. Thus we make sure we only set ;ShadowHash; if it is missing, and + # we can do this with the merge command. This allows people to continue to + # use other custom AuthenticationAuthority attributes without stomping on them. + # + # There is a potential problem here in that we're only doing this when setting + # the password, and the attribute could get modified at other times while the + # hash doesn't change and so this doesn't get called at all... but + # without switching all the other attributes to merge instead of create I can't + # see a simple enough solution for this that doesn't modify the user record + # every single time. This should be a rather rare edge case. (famous last words) + + dscl_vector = self.get_exec_preamble("-merge", resource_name) + dscl_vector << "AuthenticationAuthority" << ";ShadowHash;" + begin + dscl_output = execute(dscl_vector) + rescue Puppet::ExecutionFailure => detail + fail("Could not set AuthenticationAuthority.") + end + else + # 10.7 uses salted SHA512 password hashes which are 128 characters plus + # an 8 character salt. Previous versions used a SHA1 hash padded with + # zeroes. If someone attempts to use a password hash that worked with + # a previous version of OX X, we will fail early and warn them. + if password_hash.length != 136 + fail("OS X 10.7 requires a Salted SHA512 hash password of 136 characters. \ + Please check your password and try again.") + end + + if File.exists?("#{@@users_plist_dir}/#{resource_name}.plist") + # If a plist already exists in /var/db/dslocal/nodes/Default/users, then + # we will need to extract the binary plist from the 'ShadowHashData' + # key, log the new password into the resultant plist's 'SALTED-SHA512' + # key, and then save the entire structure back. + users_plist = Plist::parse_xml(plutil( '-convert', 'xml1', '-o', '/dev/stdout', \ + "#{@@users_plist_dir}/#{resource_name}.plist")) + + # users_plist['ShadowHashData'][0].string is actually a binary plist + # that's nested INSIDE the user's plist (which itself is a binary + # plist). + password_hash_plist = users_plist['ShadowHashData'][0].string + converted_hash_plist = convert_binary_to_xml(password_hash_plist) + + # converted_hash_plist['SALTED-SHA512'].string expects a Base64 encoded + # string. The password_hash provided as a resource attribute is a + # hex value. We need to convert the provided hex value to a Base64 + # encoded string to nest it in the converted hash plist. + converted_hash_plist['SALTED-SHA512'].string = \ + password_hash.unpack('a2'*(password_hash.size/2)).collect { |i| i.hex.chr }.join + + # Finally, we can convert the nested plist back to binary, embed it + # into the user's plist, and convert the resultant plist back to + # a binary plist. + changed_plist = convert_xml_to_binary(converted_hash_plist) + users_plist['ShadowHashData'][0].string = changed_plist + Plist::Emit.save_plist(users_plist, "#{@@users_plist_dir}/#{resource_name}.plist") + plutil('-convert', 'binary1', "#{@@users_plist_dir}/#{resource_name}.plist") + end end + end - # NBK: For shadow hashes, the user AuthenticationAuthority must contain a value of - # ";ShadowHash;". The LKDC in 10.5 makes this more interesting though as it - # will dynamically generate ;Kerberosv5;;username@LKDC:SHA1 attributes if - # missing. Thus we make sure we only set ;ShadowHash; if it is missing, and - # we can do this with the merge command. This allows people to continue to - # use other custom AuthenticationAuthority attributes without stomping on them. - # - # There is a potential problem here in that we're only doing this when setting - # the password, and the attribute could get modified at other times while the - # hash doesn't change and so this doesn't get called at all... but - # without switching all the other attributes to merge instead of create I can't - # see a simple enough solution for this that doesn't modify the user record - # every single time. This should be a rather rare edge case. (famous last words) - - dscl_vector = self.get_exec_preamble("-merge", resource_name) - dscl_vector << "AuthenticationAuthority" << ";ShadowHash;" - begin - dscl_output = execute(dscl_vector) - rescue Puppet::ExecutionFailure => detail - fail("Could not set AuthenticationAuthority.") + def self.get_password(guid, username) + # Use Puppet::Util::Package.versioncmp() to catch the scenario where a + # version '10.10' would be < '10.7' with simple string comparison. This + # if-statement only executes if the current version is less-than 10.7 + if (Puppet::Util::Package.versioncmp(get_macosx_version_major, '10.7') == -1) + password_hash = nil + password_hash_file = "#{@@password_hash_dir}/#{guid}" + if File.exists?(password_hash_file) and File.file?(password_hash_file) + fail("Could not read password hash file at #{password_hash_file}") if not File.readable?(password_hash_file) + f = File.new(password_hash_file) + password_hash = f.read + f.close + end + password_hash + else + if File.exists?("#{@@users_plist_dir}/#{username}.plist") + # If a plist exists in /var/db/dslocal/nodes/Default/users, we will + # extract the binary plist from the 'ShadowHashData' key, decode the + # salted-SHA512 password hash, and then return it. + users_plist = Plist::parse_xml(plutil('-convert', 'xml1', '-o', '/dev/stdout', "#{@@users_plist_dir}/#{username}.plist")) + if users_plist['ShadowHashData'] + # users_plist['ShadowHashData'][0].string is actually a binary plist + # that's nested INSIDE the user's plist (which itself is a binary + # plist). + password_hash_plist = users_plist['ShadowHashData'][0].string + converted_hash_plist = convert_binary_to_xml(password_hash_plist) + + # converted_hash_plist['SALTED-SHA512'].string is a Base64 encoded + # string. The password_hash provided as a resource attribute is a + # hex value. We need to convert the Base64 encoded string to a + # hex value and provide it back to Puppet. + password_hash = converted_hash_plist['SALTED-SHA512'].string.unpack("H*")[0] + password_hash + end + end + end + end + + # This method will accept a hash that has been returned from Plist::parse_xml + # and convert it to a binary plist (string value). + def self.convert_xml_to_binary(plist_data) + Puppet.debug('Converting XML plist to binary') + Puppet.debug('Executing: \'plutil -convert binary1 -o - -\'') + IO.popen('plutil -convert binary1 -o - -', mode='r+') do |io| + io.write plist_data.to_plist + io.close_write + @converted_plist = io.read end + @converted_plist end - def self.get_password(guid) - password_hash = nil - password_hash_file = "#{@@password_hash_dir}/#{guid}" - if File.exists?(password_hash_file) and File.file?(password_hash_file) - fail("Could not read password hash file at #{password_hash_file}") if not File.readable?(password_hash_file) - f = File.new(password_hash_file) - password_hash = f.read - f.close + # This method will accept a binary plist (as a string) and convert it to a + # hash via Plist::parse_xml. + def self.convert_binary_to_xml(plist_data) + Puppet.debug('Converting binary plist to XML') + Puppet.debug('Executing: \'plutil -convert xml1 -o - -\'') + IO.popen('plutil -convert xml1 -o - -', mode='r+') do |io| + io.write plist_data + io.close_write + @converted_plist = io.read end - password_hash + Puppet.debug('Converting XML values to a hash.') + @plist_hash = Plist::parse_xml(@converted_plist) + @plist_hash end # Unlike most other *nixes, OS X doesn't provide built in functionality # for automatically assigning uids and gids to accounts, so we set up these # methods for consumption by functionality like --mkusers # By default we restrict to a reasonably sane range for system accounts def self.next_system_id(id_type, min_id=20) dscl_args = ['.', '-list'] if id_type == 'uid' dscl_args << '/Users' << 'uid' elsif id_type == 'gid' dscl_args << '/Groups' << 'gid' else fail("Invalid id_type #{id_type}. Only 'uid' and 'gid' supported") end dscl_out = dscl(dscl_args) # We're ok with throwing away negative uids here. ids = dscl_out.split.compact.collect { |l| l.to_i if l.match(/^\d+$/) } ids.compact!.sort! { |a,b| a.to_f <=> b.to_f } # We're just looking for an unused id in our sorted array. ids.each_index do |i| next_id = ids[i] + 1 return next_id if ids[i+1] != next_id and next_id >= min_id end end def ensure=(ensure_value) super # We need to loop over all valid properties for the type we're # managing and call the method which sets that property value # dscl can't create everything at once unfortunately. if ensure_value == :present @resource.class.validproperties.each do |name| next if name == :ensure # LAK: We use property.sync here rather than directly calling # the settor method because the properties might do some kind # of conversion. In particular, the user gid property might # have a string and need to convert it to a number if @resource.should(name) @resource.property(name).sync elsif value = autogen(name) self.send(name.to_s + "=", value) else next end end end end def password=(passphrase) exec_arg_vector = self.class.get_exec_preamble("-read", @resource.name) exec_arg_vector << @@ns_to_ds_attribute_map[:guid] begin guid_output = execute(exec_arg_vector) guid_plist = Plist.parse_xml(guid_output) # Although GeneratedUID like all DirectoryService values can be multi-valued # according to the schema, in practice user accounts cannot have multiple UUIDs # otherwise Bad Things Happen, so we just deal with the first value. guid = guid_plist["dsAttrTypeStandard:#{@@ns_to_ds_attribute_map[:guid]}"][0] self.class.set_password(@resource.name, guid, passphrase) rescue Puppet::ExecutionFailure => detail fail("Could not set #{param} on #{@resource.class.name}[#{@resource.name}]: #{detail}") end end # NBK: we override @parent.set as we need to execute a series of commands # to deal with array values, rather than the single command nameservice.rb # expects to be returned by modifycmd. Thus we don't bother defining modifycmd. def set(param, value) self.class.validate(param, value) current_members = @property_value_cache_hash[:members] if param == :members # If we are meant to be authoritative for the group membership # then remove all existing members who haven't been specified # in the manifest. remove_unwanted_members(current_members, value) if @resource[:auth_membership] and not current_members.nil? # if they're not a member, make them one. add_members(current_members, value) else exec_arg_vector = self.class.get_exec_preamble("-create", @resource[:name]) # JJM: The following line just maps the NS name to the DS name # e.g. { :uid => 'UniqueID' } exec_arg_vector << @@ns_to_ds_attribute_map[symbolize(param)] # JJM: The following line sends the actual value to set the property to exec_arg_vector << value.to_s begin execute(exec_arg_vector) rescue Puppet::ExecutionFailure => detail fail("Could not set #{param} on #{@resource.class.name}[#{@resource.name}]: #{detail}") end end end # NBK: we override @parent.create as we need to execute a series of commands # to create objects with dscl, rather than the single command nameservice.rb # expects to be returned by addcmd. Thus we don't bother defining addcmd. def create if exists? info "already exists" return nil end # NBK: First we create the object with a known guid so we can set the contents # of the password hash if required # Shelling out sucks, but for a single use case it doesn't seem worth # requiring people install a UUID library that doesn't come with the system. # This should be revisited if Puppet starts managing UUIDs for other platform # user records. guid = %x{/usr/bin/uuidgen}.chomp exec_arg_vector = self.class.get_exec_preamble("-create", @resource[:name]) exec_arg_vector << @@ns_to_ds_attribute_map[:guid] << guid begin execute(exec_arg_vector) rescue Puppet::ExecutionFailure => detail fail("Could not set GeneratedUID for #{@resource.class.name} #{@resource.name}: #{detail}") end if value = @resource.should(:password) and value != "" self.class.set_password(@resource[:name], guid, value) end # Now we create all the standard properties Puppet::Type.type(@resource.class.name).validproperties.each do |property| next if property == :ensure value = @resource.should(property) if property == :gid and value.nil? value = self.class.next_system_id(id_type='gid') end if property == :uid and value.nil? value = self.class.next_system_id(id_type='uid') end if value != "" and not value.nil? if property == :members add_members(nil, value) else exec_arg_vector = self.class.get_exec_preamble("-create", @resource[:name]) exec_arg_vector << @@ns_to_ds_attribute_map[symbolize(property)] next if property == :password # skip setting the password here exec_arg_vector << value.to_s begin execute(exec_arg_vector) rescue Puppet::ExecutionFailure => detail fail("Could not create #{@resource.class.name} #{@resource.name}: #{detail}") end end end end end def remove_unwanted_members(current_members, new_members) current_members.each do |member| if not new_members.flatten.include?(member) cmd = [:dseditgroup, "-o", "edit", "-n", ".", "-d", member, @resource[:name]] begin execute(cmd) rescue Puppet::ExecutionFailure => detail fail("Could not remove #{member} from group: #{@resource.name}, #{detail}") end end end end def add_members(current_members, new_members) new_members.flatten.each do |new_member| if current_members.nil? or not current_members.include?(new_member) cmd = [:dseditgroup, "-o", "edit", "-n", ".", "-a", new_member, @resource[:name]] begin execute(cmd) rescue Puppet::ExecutionFailure => detail fail("Could not add #{new_member} to group: #{@resource.name}, #{detail}") end end end end def deletecmd # JJM: Like addcmd, only called when deleting the object itself # Note, this isn't used to delete properties of the object, # at least that's how I understand it... self.class.get_exec_preamble("-delete", @resource[:name]) end def getinfo(refresh = false) # JJM 2007-07-24: # Override the getinfo method, which is also defined in nameservice.rb # This method returns and sets @infohash # I'm not re-factoring the name "getinfo" because this method will be # most likely called by nameservice.rb, which I didn't write. if refresh or (! defined?(@property_value_cache_hash) or ! @property_value_cache_hash) # JJM 2007-07-24: OK, there's a bit of magic that's about to # happen... Let's see how strong my grip has become... =) # # self is a provider instance of some Puppet::Type, like # Puppet::Type::User::ProviderDirectoryservice for the case of the # user type and this provider. # # self.class looks like "user provider directoryservice", if that # helps you ... # # self.class.resource_type is a reference to the Puppet::Type class, # probably Puppet::Type::User or Puppet::Type::Group, etc... # # self.class.resource_type.validproperties is a class method, # returning an Array of the valid properties of that specific # Puppet::Type. # # So... something like [:comment, :home, :password, :shell, :uid, # :groups, :ensure, :gid] # # Ultimately, we add :name to the list, delete :ensure from the # list, then report on the remaining list. Pretty whacky, ehh? type_properties = [:name] + self.class.resource_type.validproperties type_properties.delete(:ensure) if type_properties.include? :ensure type_properties << :guid # append GeneratedUID so we just get the report here @property_value_cache_hash = self.class.single_report(@resource[:name], *type_properties) [:uid, :gid].each do |param| @property_value_cache_hash[param] = @property_value_cache_hash[param].to_i if @property_value_cache_hash and @property_value_cache_hash.include?(param) end end @property_value_cache_hash end end end + diff --git a/lib/puppet/provider/user/windows_adsi.rb b/lib/puppet/provider/user/windows_adsi.rb index 6b0a9bce7..045a84bdb 100644 --- a/lib/puppet/provider/user/windows_adsi.rb +++ b/lib/puppet/provider/user/windows_adsi.rb @@ -1,87 +1,88 @@ require 'puppet/util/adsi' Puppet::Type.type(:user).provide :windows_adsi do desc "User management for Windows." defaultfor :operatingsystem => :windows confine :operatingsystem => :windows has_features :manages_homedir, :manages_passwords def user @user ||= Puppet::Util::ADSI::User.new(@resource[:name]) end def groups user.groups.join(',') end def groups=(groups) user.set_groups(groups, @resource[:membership] == :minimum) end def create @user = Puppet::Util::ADSI::User.create(@resource[:name]) + @user.password = @resource[:password] @user.commit [:comment, :home, :groups].each do |prop| send("#{prop}=", @resource[prop]) if @resource[prop] end end def exists? Puppet::Util::ADSI::User.exists?(@resource[:name]) end def delete Puppet::Util::ADSI::User.delete(@resource[:name]) end # Only flush if we created or modified a user, not deleted def flush @user.commit if @user end def comment user['Description'] end def comment=(value) user['Description'] = value end def home user['HomeDirectory'] end def home=(value) user['HomeDirectory'] = value end def password user.password_is?( @resource[:password] ) ? @resource[:password] : :absent end def password=(value) user.password = value end def uid Puppet::Util::ADSI.sid_for_account(@resource[:name]) end def uid=(value) fail "uid is read-only" end [:gid, :shell].each do |prop| define_method(prop) { nil } define_method("#{prop}=") do |v| fail "No support for managing property #{prop} of user #{@resource[:name]} on Windows" end end def self.instances Puppet::Util::ADSI::User.map { |u| new(:ensure => :present, :name => u.name) } end end diff --git a/spec/unit/provider/nameservice/directoryservice_spec.rb b/spec/unit/provider/nameservice/directoryservice_spec.rb index 7a83d7f20..aea496614 100755 --- a/spec/unit/provider/nameservice/directoryservice_spec.rb +++ b/spec/unit/provider/nameservice/directoryservice_spec.rb @@ -1,97 +1,159 @@ #!/usr/bin/env rspec require 'spec_helper' # We use this as a reasonable way to obtain all the support infrastructure. [:user, :group].each do |type_for_this_round| provider_class = Puppet::Type.type(type_for_this_round).provider(:directoryservice) describe provider_class do before do @resource = stub("resource") @provider = provider_class.new(@resource) end it "[#6009] should handle nested arrays of members" do current = ["foo", "bar", "baz"] desired = ["foo", ["quux"], "qorp"] group = 'example' @resource.stubs(:[]).with(:name).returns(group) @resource.stubs(:[]).with(:auth_membership).returns(true) @provider.instance_variable_set(:@property_value_cache_hash, { :members => current }) %w{bar baz}.each do |del| @provider.expects(:execute).once. with([:dseditgroup, '-o', 'edit', '-n', '.', '-d', del, group]) end %w{quux qorp}.each do |add| @provider.expects(:execute).once. with([:dseditgroup, '-o', 'edit', '-n', '.', '-a', add, group]) end expect { @provider.set(:members, desired) }.should_not raise_error end end end describe 'DirectoryService.single_report' do it 'should fail on OS X < 10.4' do Puppet::Provider::NameService::DirectoryService.stubs(:get_macosx_version_major).returns("10.3") lambda { Puppet::Provider::NameService::DirectoryService.single_report('resource_name') }.should raise_error(RuntimeError, "Puppet does not support OS X versions < 10.4") end it 'should use url data on 10.4' do Puppet::Provider::NameService::DirectoryService.stubs(:get_macosx_version_major).returns("10.4") Puppet::Provider::NameService::DirectoryService.stubs(:get_ds_path).returns('Users') Puppet::Provider::NameService::DirectoryService.stubs(:list_all_present).returns( ['root', 'user1', 'user2', 'resource_name'] ) Puppet::Provider::NameService::DirectoryService.stubs(:generate_attribute_hash) Puppet::Provider::NameService::DirectoryService.stubs(:execute) Puppet::Provider::NameService::DirectoryService.expects(:parse_dscl_url_data) Puppet::Provider::NameService::DirectoryService.single_report('resource_name') end it 'should use plist data on > 10.4' do Puppet::Provider::NameService::DirectoryService.stubs(:get_macosx_version_major).returns("10.5") Puppet::Provider::NameService::DirectoryService.stubs(:get_ds_path).returns('Users') Puppet::Provider::NameService::DirectoryService.stubs(:list_all_present).returns( ['root', 'user1', 'user2', 'resource_name'] ) Puppet::Provider::NameService::DirectoryService.stubs(:generate_attribute_hash) Puppet::Provider::NameService::DirectoryService.stubs(:execute) Puppet::Provider::NameService::DirectoryService.expects(:parse_dscl_plist_data) Puppet::Provider::NameService::DirectoryService.single_report('resource_name') end end describe 'DirectoryService.get_exec_preamble' do it 'should fail on OS X < 10.4' do Puppet::Provider::NameService::DirectoryService.stubs(:get_macosx_version_major).returns("10.3") lambda { Puppet::Provider::NameService::DirectoryService.get_exec_preamble('-list') }.should raise_error(RuntimeError, "Puppet does not support OS X versions < 10.4") end it 'should use url data on 10.4' do Puppet::Provider::NameService::DirectoryService.stubs(:get_macosx_version_major).returns("10.4") Puppet::Provider::NameService::DirectoryService.stubs(:get_ds_path).returns('Users') Puppet::Provider::NameService::DirectoryService.get_exec_preamble('-list').should include("-url") end it 'should use plist data on > 10.4' do Puppet::Provider::NameService::DirectoryService.stubs(:get_macosx_version_major).returns("10.5") Puppet::Provider::NameService::DirectoryService.stubs(:get_ds_path).returns('Users') Puppet::Provider::NameService::DirectoryService.get_exec_preamble('-list').should include("-plist") end end + +describe 'DirectoryService password behavior' do + # The below is a binary plist containing a ShadowHashData key which CONTAINS + # another binary plist. The nested binary plist contains a 'SALTED-SHA512' + # key that contains a base64 encoded salted-SHA512 password hash... + let (:binary_plist) { "bplist00\324\001\002\003\004\005\006\a\bXCRAM-MD5RNT]SALTED-SHA512[RECOVERABLEO\020 \231k2\3360\200GI\201\355J\216\202\215y\243\001\206J\300\363\032\031\022\006\2359\024\257\217<\361O\020\020F\353\at\377\277\226\276c\306\254\031\037J(\235O\020D\335\006{\3744g@\377z\204\322\r\332t\021\330\n\003\246K\223\356\034!P\261\305t\035\346\352p\206\003n\247MMA\310\301Z<\366\246\023\0161W3\340\357\000\317T\t\301\311+\204\246L7\276\370\320*\245O\021\002\000k\024\221\270x\353\001\237\346D}\377?\265]\356+\243\v[\350\316a\340h\376<\322\266\327\016\306n\272r\t\212A\253L\216\214\205\016\241 [\360/\335\002#\\A\372\241a\261\346\346\\\251\330\312\365\016\n\341\017\016\225&;\322\\\004*\ru\316\372\a \362?8\031\247\231\030\030\267\315\023\v\343{@\227\301s\372h\212\000a\244&\231\366\nt\277\2036,\027bZ+\223W\212g\333`\264\331N\306\307\362\257(^~ b\262\247&\231\261t\341\231%\244\247\203eOt\365\271\201\273\330\350\363C^A\327F\214!\217hgf\e\320k\260n\315u~\336\371M\t\235k\230S\375\311\303\240\351\037d\273\321y\335=K\016`_\317\230\2612_\023K\036\350\v\232\323Y\310\317_\035\227%\237\v\340\023\016\243\233\025\306:\227\351\370\364x\234\231\266\367\016w\275\333-\351\210}\375x\034\262\272kRuHa\362T/F!\347B\231O`K\304\037'k$$\245h)e\363\365mT\b\317\\2\361\026\351\254\375Jl1~\r\371\267\352\2322I\341\272\376\243^Un\266E7\230[VocUJ\220N\2116D/\025f=\213\314\325\vG}\311\360\377DT\307m\261&\263\340\272\243_\020\271rG^BW\210\030l\344\0324\335\233\300\023\272\225Im\330\n\227*Yv[\006\315\330y'\a\321\373\273A\240\305F{S\246I#/\355\2425\031\031GGF\270y\n\331\004\023G@\331\000\361\343\350\264$\032\355_\210y\000\205\342\375\212q\024\004\026W:\205 \363v?\035\270L-\270=\022\323\2003\v\336\277\t\237\356\374\n\267n\003\367\342\330;\371S\326\016`B6@Njm>\240\021%\336\345\002(P\204Yn\3279l\0228\264\254\304\2528t\372h\217\347sA\314\345\245\337)]\000\b\000\021\000\032\000\035\000+\0007\000Z\000m\000\264\000\000\000\000\000\000\002\001\000\000\000\000\000\000\000\t\000\000\000\000\000\000\000\000\000\000\000\000\000\000\002\270" } + + # The below is a base64 encoded salted-SHA512 password hash. + let (:pw_string) { "\335\006{\3744g@\377z\204\322\r\332t\021\330\n\003\246K\223\356\034!P\261\305t\035\346\352p\206\003n\247MMA\310\301Z<\366\246\023\0161W3\340\357\000\317T\t\301\311+\204\246L7\276\370\320*\245" } + + # The below is a salted-SHA512 password hash in hex. + let (:sha512_hash) { 'dd067bfc346740ff7a84d20dda7411d80a03a64b93ee1c2150b1c5741de6ea7086036ea74d4d41c8c15a3cf6a6130e315733e0ef00cf5409c1c92b84a64c37bef8d02aa5' } + + let :plist_path do + '/var/db/dslocal/nodes/Default/users/jeff.plist' + end + + let :ds_provider do + Puppet::Provider::NameService::DirectoryService + end + + let :shadow_hash_data do + {'ShadowHashData' => [StringIO.new(binary_plist)]} + end + + subject do + Puppet::Provider::NameService::DirectoryService + end + + before :each do + subject.expects(:get_macosx_version_major).returns("10.7") + end + + it 'should execute convert_binary_to_xml once when getting the password on >= 10.7' do + subject.expects(:convert_binary_to_xml).returns({'SALTED-SHA512' => StringIO.new(pw_string)}) + File.expects(:exists?).with(plist_path).once.returns(true) + Plist.expects(:parse_xml).returns(shadow_hash_data) + # On Mac OS X 10.7 we first need to convert to xml when reading the password + subject.expects(:plutil).with('-convert', 'xml1', '-o', '/dev/stdout', plist_path) + subject.get_password('uid', 'jeff') + end + + it 'should fail if a salted-SHA512 password hash is not passed in >= 10.7' do + expect { + subject.set_password('jeff', 'uid', 'badpassword') + }.should raise_error(RuntimeError, /OS X 10.7 requires a Salted SHA512 hash password of 136 characters./) + end + + it 'should convert xml-to-binary and binary-to-xml when setting the pw on >= 10.7' do + subject.expects(:convert_binary_to_xml).returns({'SALTED-SHA512' => StringIO.new(pw_string)}) + subject.expects(:convert_xml_to_binary).returns(binary_plist) + File.expects(:exists?).with(plist_path).once.returns(true) + Plist.expects(:parse_xml).returns(shadow_hash_data) + # On Mac OS X 10.7 we first need to convert to xml + subject.expects(:plutil).with('-convert', 'xml1', '-o', '/dev/stdout', plist_path) + # And again back to a binary plist or DirectoryService will complain + subject.expects(:plutil).with('-convert', 'binary1', plist_path) + Plist::Emit.expects(:save_plist).with(shadow_hash_data, plist_path) + subject.set_password('jeff', 'uid', sha512_hash) + end +end + diff --git a/spec/unit/provider/user/windows_adsi_spec.rb b/spec/unit/provider/user/windows_adsi_spec.rb index 6eff947e0..23fba9983 100755 --- a/spec/unit/provider/user/windows_adsi_spec.rb +++ b/spec/unit/provider/user/windows_adsi_spec.rb @@ -1,150 +1,151 @@ #!/usr/bin/env ruby require 'spec_helper' describe Puppet::Type.type(:user).provider(:windows_adsi) do let(:resource) do Puppet::Type.type(:user).new( :title => 'testuser', :comment => 'Test J. User', :provider => :windows_adsi ) end let(:provider) { resource.provider } let(:connection) { stub 'connection' } before :each do Puppet::Util::ADSI.stubs(:computer_name).returns('testcomputername') Puppet::Util::ADSI.stubs(:connect).returns connection end describe ".instances" do it "should enumerate all users" do names = ['user1', 'user2', 'user3'] stub_users = names.map{|n| stub(:name => n)} connection.stubs(:execquery).with("select * from win32_useraccount").returns(stub_users) described_class.instances.map(&:name).should =~ names end end it "should provide access to a Puppet::Util::ADSI::User object" do provider.user.should be_a(Puppet::Util::ADSI::User) end describe "when managing groups" do it 'should return the list of groups as a comma-separated list' do provider.user.stubs(:groups).returns ['group1', 'group2', 'group3'] provider.groups.should == 'group1,group2,group3' end it "should return absent if there are no groups" do provider.user.stubs(:groups).returns [] provider.groups.should == '' end it 'should be able to add a user to a set of groups' do resource[:membership] = :minimum provider.user.expects(:set_groups).with('group1,group2', true) provider.groups = 'group1,group2' resource[:membership] = :inclusive provider.user.expects(:set_groups).with('group1,group2', false) provider.groups = 'group1,group2' end end describe "when creating a user" do it "should create the user on the system and set its other properties" do resource[:groups] = ['group1', 'group2'] resource[:membership] = :inclusive resource[:comment] = 'a test user' resource[:home] = 'C:\Users\testuser' user = stub 'user' Puppet::Util::ADSI::User.expects(:create).with('testuser').returns user user.stubs(:groups).returns(['group2', 'group3']) create = sequence('create') + user.expects(:password=).in_sequence(create) user.expects(:commit).in_sequence(create) user.expects(:set_groups).with('group1,group2', false).in_sequence(create) user.expects(:[]=).with('Description', 'a test user') user.expects(:[]=).with('HomeDirectory', 'C:\Users\testuser') provider.create end it "should set a user's password" do provider.user.expects(:password=).with('plaintextbad') provider.password = "plaintextbad" end it "should test a valid user password" do resource[:password] = 'plaintext' provider.user.expects(:password_is?).with('plaintext').returns true provider.password.should == 'plaintext' end it "should test a bad user password" do resource[:password] = 'plaintext' provider.user.expects(:password_is?).with('plaintext').returns false provider.password.should == :absent end it 'should not create a user if a group by the same name exists' do Puppet::Util::ADSI::User.expects(:create).with('testuser').raises( Puppet::Error.new("Cannot create user if group 'testuser' exists.") ) expect{ provider.create }.to raise_error( Puppet::Error, /Cannot create user if group 'testuser' exists./ ) end end it 'should be able to test whether a user exists' do Puppet::Util::ADSI.stubs(:connect).returns stub('connection') provider.should be_exists Puppet::Util::ADSI.stubs(:connect).returns nil provider.should_not be_exists end it 'should be able to delete a user' do connection.expects(:Delete).with('user', 'testuser') provider.delete end it "should commit the user when flushed" do provider.user.expects(:commit) provider.flush end it "should return the user's SID as uid" do Puppet::Util::ADSI.expects(:sid_for_account).with('testuser').returns('S-1-5-21-1362942247-2130103807-3279964888-1111') provider.uid.should == 'S-1-5-21-1362942247-2130103807-3279964888-1111' end it "should fail when trying to manage the uid property" do provider.expects(:fail).with { |msg| msg =~ /uid is read-only/ } provider.send(:uid=, 500) end [:gid, :shell].each do |prop| it "should fail when trying to manage the #{prop} property" do provider.expects(:fail).with { |msg| msg =~ /No support for managing property #{prop}/ } provider.send("#{prop}=", 'foo') end end end diff --git a/spec/unit/util/execution_stub_spec.rb b/spec/unit/util/execution_stub_spec.rb index 57305e315..b66f88f83 100755 --- a/spec/unit/util/execution_stub_spec.rb +++ b/spec/unit/util/execution_stub_spec.rb @@ -1,39 +1,40 @@ #!/usr/bin/env rspec require 'spec_helper' describe Puppet::Util::ExecutionStub do it "should use the provided stub code when 'set' is called" do Puppet::Util::ExecutionStub.set do |command, options| command.should == ['/bin/foo', 'bar'] "stub output" end Puppet::Util::ExecutionStub.current_value.should_not == nil Puppet::Util.execute(['/bin/foo', 'bar']).should == "stub output" end it "should automatically restore normal execution at the conclusion of each spec test" do # Note: this test relies on the previous test creating a stub. Puppet::Util::ExecutionStub.current_value.should == nil end - it "should restore normal execution after 'reset' is called" do + # fails on windows, see #11740 + it "should restore normal execution after 'reset' is called", :fails_on_windows => true do # Note: "true" exists at different paths in different OSes if Puppet.features.microsoft_windows? true_command = [Puppet::Util.which('cmd.exe').tr('/', '\\'), '/c', 'exit 0'] else true_command = [Puppet::Util.which('true')] end stub_call_count = 0 Puppet::Util::ExecutionStub.set do |command, options| command.should == true_command stub_call_count += 1 'stub called' end Puppet::Util.execute(true_command).should == 'stub called' stub_call_count.should == 1 Puppet::Util::ExecutionStub.reset Puppet::Util::ExecutionStub.current_value.should == nil Puppet::Util.execute(true_command).should == '' stub_call_count.should == 1 end end