macadmin 0.0.4
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.gitignore +17 -0
- data/.travis.yml +4 -0
- data/Gemfile +4 -0
- data/LICENSE.txt +22 -0
- data/README.md +163 -0
- data/Rakefile +23 -0
- data/ext/macadmin/password/crypto.c +89 -0
- data/ext/macadmin/password/extconf.rb +3 -0
- data/lib/macadmin.rb +22 -0
- data/lib/macadmin/common.rb +80 -0
- data/lib/macadmin/dslocal.rb +252 -0
- data/lib/macadmin/dslocal/computer.rb +22 -0
- data/lib/macadmin/dslocal/computergroup.rb +19 -0
- data/lib/macadmin/dslocal/dslocalnode.rb +281 -0
- data/lib/macadmin/dslocal/group.rb +82 -0
- data/lib/macadmin/dslocal/user.rb +113 -0
- data/lib/macadmin/mcx.rb +227 -0
- data/lib/macadmin/password.rb +72 -0
- data/lib/macadmin/shadowhash.rb +297 -0
- data/lib/macadmin/version.rb +8 -0
- data/macadmin.gemspec +35 -0
- data/spec/common_spec.rb +49 -0
- data/spec/computer_spec.rb +133 -0
- data/spec/computergroup_spec.rb +274 -0
- data/spec/dslocal_spec.rb +179 -0
- data/spec/dslocalnode_spec.rb +343 -0
- data/spec/group_spec.rb +275 -0
- data/spec/macadmin_spec.rb +13 -0
- data/spec/mcx_spec.rb +145 -0
- data/spec/password_spec.rb +40 -0
- data/spec/shadowhash_spec.rb +309 -0
- data/spec/spec_helper.rb +9 -0
- data/spec/user_spec.rb +248 -0
- metadata +218 -0
@@ -0,0 +1,82 @@
|
|
1
|
+
module MacAdmin
|
2
|
+
|
3
|
+
# Group
|
4
|
+
# - creates and manages Mac OS X User Groups
|
5
|
+
# - params: :name, :realname, :gid
|
6
|
+
class Group < DSLocalRecord
|
7
|
+
|
8
|
+
MIN_GID = 501
|
9
|
+
|
10
|
+
def initialize(args)
|
11
|
+
@member_class = User unless defined? @member_class
|
12
|
+
@group_class = Group unless defined? @group_class
|
13
|
+
super args
|
14
|
+
end
|
15
|
+
|
16
|
+
# Examine the object's users array for "member"
|
17
|
+
# - single param: the name of a user (String)
|
18
|
+
def has_user?(member)
|
19
|
+
self[:users].member? member
|
20
|
+
end
|
21
|
+
|
22
|
+
# Add a member to the object's users array
|
23
|
+
# - single param: the name of a user (String)
|
24
|
+
def add_user(member)
|
25
|
+
user = @member_class.new :name => member, :node => self.node
|
26
|
+
raise unless user.exists?
|
27
|
+
self[:users] << member
|
28
|
+
self[:users].uniq!
|
29
|
+
end
|
30
|
+
|
31
|
+
# Remove a member from the object's users array
|
32
|
+
# - single param: the name of a user (String)
|
33
|
+
def rm_user(member)
|
34
|
+
self[:users].delete member
|
35
|
+
end
|
36
|
+
|
37
|
+
# Examine the object's groupmembers array for "member"
|
38
|
+
# - single param: the name of a group (String)
|
39
|
+
def has_groupmember?(member)
|
40
|
+
group = @group_class.new :name => member, :node => self.node
|
41
|
+
return false unless group.exists?
|
42
|
+
self[:groupmembers].member? group.generateduid.first
|
43
|
+
end
|
44
|
+
|
45
|
+
# Add a member to the object's groupmembers array
|
46
|
+
# - single param: the name of a group (String)
|
47
|
+
def add_groupmember(member)
|
48
|
+
group = @group_class.new :name => member, :node => self.node
|
49
|
+
raise unless group.exists?
|
50
|
+
self[:groupmembers] << group.generateduid.first
|
51
|
+
self[:groupmembers].uniq!
|
52
|
+
end
|
53
|
+
|
54
|
+
# Remove a member from the object's groupmembers array
|
55
|
+
# - single param: the name of a group (String)
|
56
|
+
def rm_groupmember(member)
|
57
|
+
group = @group_class.new :name => member, :node => self.node
|
58
|
+
return nil unless group.exists?
|
59
|
+
self[:groupmembers].delete group.generateduid.first
|
60
|
+
end
|
61
|
+
|
62
|
+
private
|
63
|
+
|
64
|
+
# Handle required but unspecified record attributes
|
65
|
+
# - generates missing attributes
|
66
|
+
# - changes are merged into the composite record
|
67
|
+
def defaults(data)
|
68
|
+
records = all_records(@node)
|
69
|
+
next_gid = next_id(MIN_GID, get_all_attribs_of_type(:gid, records))
|
70
|
+
defaults = {
|
71
|
+
'realname' => ["#{data['name'].first.capitalize}"],
|
72
|
+
'gid' => ["#{next_gid}"],
|
73
|
+
'passwd' => ['*'],
|
74
|
+
'groupmembers' => [],
|
75
|
+
'users' => [],
|
76
|
+
}
|
77
|
+
super defaults.merge(data)
|
78
|
+
end
|
79
|
+
|
80
|
+
end
|
81
|
+
|
82
|
+
end
|
@@ -0,0 +1,113 @@
|
|
1
|
+
module MacAdmin
|
2
|
+
|
3
|
+
# User
|
4
|
+
# - creates and manages Mac OS X User accounts
|
5
|
+
# - params: :name, :realname, :password, :uid, :gid, :shell, :home, :comment
|
6
|
+
class User < DSLocalRecord
|
7
|
+
|
8
|
+
MIN_UID = 501
|
9
|
+
|
10
|
+
# Override parent initialization
|
11
|
+
# - capture the password if it exists
|
12
|
+
# - if there's no password object param, try to get one
|
13
|
+
# - shoehorn the password into the User record
|
14
|
+
def initialize(args)
|
15
|
+
if args.respond_to?(:keys)
|
16
|
+
@password = args.delete(:password)
|
17
|
+
end
|
18
|
+
super(args)
|
19
|
+
if @password
|
20
|
+
self.send(:password=)
|
21
|
+
else
|
22
|
+
@password = ShadowHash.create_from_user_record(self)
|
23
|
+
end
|
24
|
+
end
|
25
|
+
|
26
|
+
# Generic setter
|
27
|
+
# - Accepts a ShadowHash object
|
28
|
+
# - delegates the storage operation to the ShadowHash object itself
|
29
|
+
def password=(password = @password)
|
30
|
+
error = 'Argument was not a ShadowHash object'
|
31
|
+
unless password.nil? or password.respond_to? :password
|
32
|
+
raise ArgumentError.new(error)
|
33
|
+
end
|
34
|
+
@password = password
|
35
|
+
@password.send(:store, self) unless @password.nil?
|
36
|
+
end
|
37
|
+
|
38
|
+
# Generic getter
|
39
|
+
# - Returns Ruby Hash representation of the User's password
|
40
|
+
def password
|
41
|
+
return nil unless @password
|
42
|
+
@password.password
|
43
|
+
end
|
44
|
+
|
45
|
+
# Legacy user records are determined by SHA1 password type
|
46
|
+
# - returns boolean
|
47
|
+
def legacy?
|
48
|
+
@password.is_a? SaltedSHA1
|
49
|
+
end
|
50
|
+
|
51
|
+
# Does the specified resource already exist?
|
52
|
+
# - overrides parent method; required for Legacy users
|
53
|
+
# - checks the password if SHA1 and kicks up to parent
|
54
|
+
def exists?
|
55
|
+
if self.legacy?
|
56
|
+
password_on_disk = SaltedSHA1.create_from_shadowhash_file self.generateduid[0]
|
57
|
+
return false unless password_on_disk
|
58
|
+
return false unless password_on_disk.password.eql? @password.password
|
59
|
+
end
|
60
|
+
super
|
61
|
+
end
|
62
|
+
|
63
|
+
# Create the record
|
64
|
+
# - overrides parent method; required for Legacy users
|
65
|
+
# - creates the password if SHA1 and kicks up to parent
|
66
|
+
def create(file=@file)
|
67
|
+
if self.legacy?
|
68
|
+
return unless @password.send(:to_file, self)
|
69
|
+
end
|
70
|
+
super
|
71
|
+
end
|
72
|
+
|
73
|
+
# Delete the record
|
74
|
+
# - overrides parent method; required for Legacy users
|
75
|
+
# - destroys the password if SHA1 and kicks up to parent
|
76
|
+
def destroy(file=@file)
|
77
|
+
if self.legacy?
|
78
|
+
return unless @password.send(:rm_file, self)
|
79
|
+
end
|
80
|
+
super
|
81
|
+
end
|
82
|
+
|
83
|
+
# Return a Puppet style resource manifest
|
84
|
+
# - need to find a sensible way of doing this
|
85
|
+
# - need to move this method into the Parent class
|
86
|
+
def to_puppet
|
87
|
+
puts "not implemented"
|
88
|
+
end
|
89
|
+
|
90
|
+
private
|
91
|
+
|
92
|
+
# Handle required but unspecified record attributes
|
93
|
+
# - generates missing attributes
|
94
|
+
# - changes are merged into the composite record
|
95
|
+
def defaults(data)
|
96
|
+
records = all_records(@node)
|
97
|
+
next_uid = next_id(MIN_UID, get_all_attribs_of_type(:uid, records))
|
98
|
+
defaults = {
|
99
|
+
'realname' => ["#{data['name'].first}"],
|
100
|
+
'uid' => ["#{next_uid}"],
|
101
|
+
'home' => ["/Users/#{data['name'].first}"],
|
102
|
+
'shell' => ['/bin/bash'],
|
103
|
+
'gid' => ['20'],
|
104
|
+
'passwd' => ['********'],
|
105
|
+
'comment' => [''],
|
106
|
+
}
|
107
|
+
super defaults.merge(data)
|
108
|
+
end
|
109
|
+
|
110
|
+
end
|
111
|
+
|
112
|
+
end
|
113
|
+
|
data/lib/macadmin/mcx.rb
ADDED
@@ -0,0 +1,227 @@
|
|
1
|
+
module MacAdmin
|
2
|
+
|
3
|
+
# MCX
|
4
|
+
# - methods and classes mixed into MacAdmin::DSLocalRecord for managing MCX policy
|
5
|
+
module MCX
|
6
|
+
|
7
|
+
# Policy
|
8
|
+
# - document format for mcx_export
|
9
|
+
class Policy
|
10
|
+
|
11
|
+
MANIFESTS = '/System/Library/CoreServices/ManagedClient.app/Contents/Resources'
|
12
|
+
|
13
|
+
def initialize(mcx_settings)
|
14
|
+
@documents = mcx_settings
|
15
|
+
@policy = process_documents(@documents)
|
16
|
+
end
|
17
|
+
|
18
|
+
# Dump the document in a human-readable format
|
19
|
+
def to_plist
|
20
|
+
@policy.to_plist({:plist_format => CFPropertyList::List::FORMAT_XML, :formatted => true})
|
21
|
+
end
|
22
|
+
|
23
|
+
private
|
24
|
+
|
25
|
+
# Process each of the domain documents
|
26
|
+
def process_documents(documents)
|
27
|
+
documents.inject({}) do |dict, doc|
|
28
|
+
plist = CFPropertyList::List.new(:data => doc)
|
29
|
+
native = CFPropertyList.native_types(plist.value)
|
30
|
+
native['mcx_application_data'].inject({}) do |result, (domain, domain_dict)|
|
31
|
+
dict[domain] = process_domain(domain, domain_dict)
|
32
|
+
dict
|
33
|
+
end
|
34
|
+
end
|
35
|
+
end
|
36
|
+
|
37
|
+
# Process the domain preference document
|
38
|
+
# - flattens and reformats the domain document into constituent keys
|
39
|
+
def process_domain(domain, domain_dict)
|
40
|
+
manifest = "#{MANIFESTS}/#{domain}.manifest/Contents/Resources/#{domain}.manifest"
|
41
|
+
upk_subkeys = load_upk_subkeys(manifest)
|
42
|
+
domain_dict.inject({}) do |result, (enforcement, enforcement_array)|
|
43
|
+
enforcement_array.inject({}) do |hash, dict|
|
44
|
+
state = dict.include?('mcx_data_timestamp') ? 'once' : 'often'
|
45
|
+
state = 'always' if enforcement.eql? 'Forced'
|
46
|
+
dict['mcx_preference_settings'].each do |name, value|
|
47
|
+
result[name] = { 'state' => state, 'value' => value }
|
48
|
+
if upk_subkeys
|
49
|
+
upk = get_upk_info(name, state, upk_subkeys)
|
50
|
+
result[name]['upk'] = get_upk_info(name, state, upk_subkeys) if upk
|
51
|
+
end
|
52
|
+
result
|
53
|
+
end
|
54
|
+
end
|
55
|
+
result
|
56
|
+
end
|
57
|
+
end
|
58
|
+
|
59
|
+
# Load the domain's preference manifest and return the UPK subkeys
|
60
|
+
# - returns Array
|
61
|
+
def load_upk_subkeys(manifest)
|
62
|
+
upk_subkeys = []
|
63
|
+
if File.exists? manifest
|
64
|
+
plist = CFPropertyList::List.new(:file => manifest)
|
65
|
+
native = CFPropertyList.native_types(plist.value)
|
66
|
+
upk_subkeys = native['pfm_subkeys'].select do |dict|
|
67
|
+
if dict['pfm_type'].eql? 'union policy'
|
68
|
+
dict
|
69
|
+
end
|
70
|
+
end
|
71
|
+
end
|
72
|
+
upk_subkeys
|
73
|
+
end
|
74
|
+
|
75
|
+
# Determine the most appropriate UPK keys for the given preference
|
76
|
+
# - returns Hash
|
77
|
+
def get_upk_info(name, state, upk_subkeys)
|
78
|
+
info = nil
|
79
|
+
if upk_subkeys
|
80
|
+
results = upk_subkeys.inject({}) do |results, dict|
|
81
|
+
if dict['pfm_upk_input_keys'].include? name
|
82
|
+
results[dict['pfm_upk_output_name']] = dict
|
83
|
+
end
|
84
|
+
results
|
85
|
+
end
|
86
|
+
if results
|
87
|
+
if results.size > 1
|
88
|
+
if state.eql? 'always'
|
89
|
+
results = results.select { |k,v| v if k =~ /-managed\z/ }
|
90
|
+
info = results.flatten.last
|
91
|
+
else
|
92
|
+
results = results.select { |k,v| v if k =~ /\Auser\z/ }
|
93
|
+
info = results.flatten.last
|
94
|
+
end
|
95
|
+
else
|
96
|
+
info = results.first
|
97
|
+
end
|
98
|
+
end
|
99
|
+
end
|
100
|
+
if info
|
101
|
+
upk = { 'mcx_input_key_names' => info['pfm_upk_input_keys'],
|
102
|
+
'mcx_output_key_name' => info['pfm_upk_output_name'],
|
103
|
+
'mcx_remove_duplicates' => info['pfm_remove_duplicates']
|
104
|
+
}
|
105
|
+
return upk
|
106
|
+
end
|
107
|
+
nil
|
108
|
+
end
|
109
|
+
|
110
|
+
end
|
111
|
+
|
112
|
+
# EmbeddedDocument
|
113
|
+
# - domain level MCX document suitable for storage in the record's mcx_settings array
|
114
|
+
class EmbeddedDocument
|
115
|
+
|
116
|
+
attr_reader :document
|
117
|
+
|
118
|
+
def initialize(domain, content)
|
119
|
+
@document = { 'mcx_application_data' => {} }
|
120
|
+
@domain = domain
|
121
|
+
@content = process(content)
|
122
|
+
end
|
123
|
+
|
124
|
+
def formatted
|
125
|
+
@document.to_plist({:plist_format => CFPropertyList::List::FORMAT_XML, :formatted => true})
|
126
|
+
end
|
127
|
+
|
128
|
+
def escaped
|
129
|
+
CGI.escapeHTML @document.to_plist({:plist_format => CFPropertyList::List::FORMAT_XML, :formatted => true})
|
130
|
+
end
|
131
|
+
|
132
|
+
private
|
133
|
+
|
134
|
+
def process(content)
|
135
|
+
@document['mcx_application_data'][@domain] = {}
|
136
|
+
content.each do |pref_name, pref_dict|
|
137
|
+
state = pref_dict['state']
|
138
|
+
enforcement = (state.eql?('always') ? 'Forced' : 'Set-Once')
|
139
|
+
@document['mcx_application_data'][@domain][enforcement] ||= [{}]
|
140
|
+
if pref_dict['upk']
|
141
|
+
@document['mcx_application_data'][@domain][enforcement][0]['mcx_union_policy_keys'] ||= []
|
142
|
+
@document['mcx_application_data'][@domain][enforcement][0]['mcx_union_policy_keys'] << pref_dict['upk']
|
143
|
+
end
|
144
|
+
@document['mcx_application_data'][@domain][enforcement][0]['mcx_preference_settings'] ||= {}
|
145
|
+
@document['mcx_application_data'][@domain][enforcement][0]['mcx_preference_settings'][pref_name] = pref_dict['value']
|
146
|
+
if state.eql? 'once'
|
147
|
+
@document['mcx_application_data'][@domain][enforcement][0]['mcx_data_timestamp'] = CFPropertyList::CFDate.parse_date(Time.now.utc.xmlschema)
|
148
|
+
end
|
149
|
+
end
|
150
|
+
end
|
151
|
+
|
152
|
+
end
|
153
|
+
|
154
|
+
# Settings
|
155
|
+
# - class representing the structure of the mcx_settings array
|
156
|
+
class Settings
|
157
|
+
|
158
|
+
attr_reader :domains
|
159
|
+
|
160
|
+
XML_TAG = '^<\?xml\sversion="1\.0"\sencoding="UTF-8"\?>'
|
161
|
+
|
162
|
+
class << self
|
163
|
+
def init_with_file(path)
|
164
|
+
content = load_plist(path)
|
165
|
+
self.new(content)
|
166
|
+
end
|
167
|
+
end
|
168
|
+
|
169
|
+
def initialize(content, type=:data)
|
170
|
+
type = :file unless content =~ /#{XML_TAG}/
|
171
|
+
plist = CFPropertyList::List.new(type => content)
|
172
|
+
native = CFPropertyList.native_types(plist.value)
|
173
|
+
@domains = []
|
174
|
+
@content = process(native)
|
175
|
+
end
|
176
|
+
|
177
|
+
private
|
178
|
+
|
179
|
+
def process(content)
|
180
|
+
content.each do |domain_name, domain_dict|
|
181
|
+
doc = EmbeddedDocument.new domain_name, domain_dict
|
182
|
+
@domains << doc.formatted
|
183
|
+
end
|
184
|
+
end
|
185
|
+
|
186
|
+
end
|
187
|
+
|
188
|
+
# Import MCX Content and apply it to the current object
|
189
|
+
# - accepts a single parameter: path to Plist file containing exported MCX policy or a string of XML content representing the MCX policy
|
190
|
+
# - re-formats the imported MCX for storage on the record and adds the two require attributes: mcx_flags and mcx_settings
|
191
|
+
# - current implmentation replaces policy wholesale (no append)
|
192
|
+
def mcx_import(content, append=false)
|
193
|
+
settings = Settings.new content
|
194
|
+
mcx_flags = { 'has_mcx_settings' => true }
|
195
|
+
mcx_flags = mcx_flags.to_plist({:plist_format => CFPropertyList::List::FORMAT_XML, :formatted => true})
|
196
|
+
self['mcx_flags'] = [CFPropertyList::Blob.new(mcx_flags)]
|
197
|
+
self['mcx_settings'] = settings.domains
|
198
|
+
end
|
199
|
+
alias :mcximport :mcx_import
|
200
|
+
|
201
|
+
# Export the MCX preferences for the record
|
202
|
+
def mcx_export
|
203
|
+
doc = Policy.new self['mcx_settings']
|
204
|
+
doc.to_plist
|
205
|
+
end
|
206
|
+
alias :mcxexport :mcx_export
|
207
|
+
|
208
|
+
# Pretty print the contents of the record's mcx_settings array
|
209
|
+
def pretty_mcx
|
210
|
+
self['mcx_settings'].collect { |doc| CGI.unescapeHTML doc }
|
211
|
+
end
|
212
|
+
|
213
|
+
# Does the object have any MCX policy?
|
214
|
+
def has_mcx?
|
215
|
+
self.has_key? 'mcx_settings' and self['mcx_settings'].is_a? Array and not self['mcx_settings'].empty?
|
216
|
+
end
|
217
|
+
|
218
|
+
# Remove all MCX policy from the object
|
219
|
+
def mcx_delete
|
220
|
+
self.delete('mcx_flags')
|
221
|
+
self.delete('mcx_settings')
|
222
|
+
end
|
223
|
+
alias :mcxdelete :mcx_delete
|
224
|
+
|
225
|
+
end
|
226
|
+
|
227
|
+
end
|
@@ -0,0 +1,72 @@
|
|
1
|
+
module MacAdmin
|
2
|
+
|
3
|
+
# Password
|
4
|
+
# - module containing methods for converting a plain String into Mac OS X password hash
|
5
|
+
module Password
|
6
|
+
|
7
|
+
require 'openssl'
|
8
|
+
require 'securerandom'
|
9
|
+
|
10
|
+
extend self
|
11
|
+
|
12
|
+
# Convert ASCII string to hex bytes
|
13
|
+
def convert_to_hex(string)
|
14
|
+
string.unpack('H*').first
|
15
|
+
end
|
16
|
+
|
17
|
+
# Convert hex string to CFBlob
|
18
|
+
def convert_to_blob(hex)
|
19
|
+
ascii = hex.scan(/../).collect { |byte| byte.hex.chr }.join
|
20
|
+
CFPropertyList::Blob.new(ascii)
|
21
|
+
end
|
22
|
+
|
23
|
+
# This method is only available in Mountain Lion or better
|
24
|
+
if MacAdmin::Common::MAC_OS_X_PRODUCT_VERSION > 10.7
|
25
|
+
|
26
|
+
require "macadmin/password/crypto"
|
27
|
+
|
28
|
+
# Creates a SaltedSHA512PBKDF2 password from String
|
29
|
+
# - single param: String
|
30
|
+
# - returns: SaltedSHA512PBKDF2
|
31
|
+
def salted_sha512_pbkdf2(password)
|
32
|
+
hash = salted_sha512_pbkdf2_from_string password
|
33
|
+
SaltedSHA512PBKDF2.new hash
|
34
|
+
end
|
35
|
+
|
36
|
+
end
|
37
|
+
|
38
|
+
# Creates a SaltedSHA512 password from String
|
39
|
+
# - single param: String
|
40
|
+
# - returns: SaltedSHA512
|
41
|
+
def salted_sha512(password)
|
42
|
+
salt = SecureRandom.random_bytes(4)
|
43
|
+
hash = Digest::SHA512.hexdigest(salt + password)
|
44
|
+
SaltedSHA512.new(convert_to_hex(salt) + hash)
|
45
|
+
end
|
46
|
+
|
47
|
+
# Creates a SaltedSHA1 password from String
|
48
|
+
# - single param: String
|
49
|
+
# - returns: SaltedSHA1
|
50
|
+
def salted_sha1(password)
|
51
|
+
salt = SecureRandom.random_bytes(4)
|
52
|
+
hash = Digest::SHA1.hexdigest(salt + password)
|
53
|
+
SaltedSHA1.new((convert_to_hex(salt) + hash).upcase)
|
54
|
+
end
|
55
|
+
|
56
|
+
# Create a platform appropriate password
|
57
|
+
# - single param: String
|
58
|
+
# - returns: SaltedSHA512PBKDF2 or SaltedSHA512 or SaltedSHA1 depending on platform
|
59
|
+
def apropos(password)
|
60
|
+
platform = MacAdmin::Common::MAC_OS_X_PRODUCT_VERSION
|
61
|
+
if platform >= 10.8
|
62
|
+
return salted_sha512_pbkdf2 password
|
63
|
+
elsif platform == 10.7
|
64
|
+
return salted_sha512 password
|
65
|
+
else
|
66
|
+
return salted_sha1 password
|
67
|
+
end
|
68
|
+
end
|
69
|
+
|
70
|
+
end
|
71
|
+
|
72
|
+
end
|