macadmin 0.0.4

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,252 @@
1
+ module MacAdmin
2
+
3
+ # Stub Error class
4
+ class DSLocalError < StandardError
5
+ end
6
+
7
+ # DSLocalRecord (super class)
8
+ # - this is the raw constructor class for DSLocal records
9
+ # - records of 'type' should be created using one of the provided subclasses
10
+ # - this class delegates to Hash and therefore behaves as though it were one
11
+ # - added method_missing? to do fancy dot-style attribute returns
12
+ class DSLocalRecord < DelegateClass(Hash)
13
+
14
+ include MacAdmin::Common
15
+ include MacAdmin::MCX
16
+
17
+ # Where all the files on disk live
18
+ DSLOCAL_ROOT = '/private/var/db/dslocal/nodes'
19
+
20
+ # Some reader attributes for introspection and debugging
21
+ attr_reader :data, :composite, :real, :file, :record, :node
22
+ attr_accessor :file
23
+
24
+ class << self
25
+
26
+ # Inits a record from a file on disk
27
+ # - param is a path to a DSLocal Property List file
28
+ # - if file is invalid, return nil
29
+ def init_with_file(file)
30
+ data = load_plist file
31
+ return nil unless data
32
+ self.new :name => data['name'].first, :file => file, :real => data
33
+ end
34
+
35
+ end
36
+
37
+ # Create a new DSLocalRecord
38
+ # - this method is not meant to be called directly; use subclasses instead
39
+ # - params are valid DSLocalRecord attributes
40
+ # - when a node is not specified, 'Default' is assumed
41
+ def initialize(args)
42
+ @real = (args.delete(:real) { nil }) unless args.is_a? String
43
+ @data = normalize(args)
44
+ @name = @data['name'].first
45
+ @record_type = record_type
46
+ @node = (@data.delete('node') { ['Default'] }).first.to_s
47
+ @file = (@data.delete('file') { ["#{DSLOCAL_ROOT}/#{@node}/#{@record_type + 's'}/#{@name}.plist"] }).first.to_s
48
+ @record = synthesize(@data)
49
+ super(@record)
50
+ end
51
+
52
+ # Does the specified resource already exist?
53
+ # - returns Boolean
54
+ def exists?
55
+ @real = load_plist @file
56
+ @composite.eql? @real
57
+ end
58
+
59
+ # Create the record
60
+ # - simply writes the compiled Hash to disk
61
+ # - converts ShadowHashData attrib to CFPropertyList::Blob before writing
62
+ # - will accept an alternate path than the default; useful for debugging
63
+ def create(file = @file)
64
+ out = @record.dup
65
+ if shadowhashdata = out['ShadowHashData']
66
+ out['ShadowHashData'] = [CFPropertyList::Blob.new(shadowhashdata.first)]
67
+ end
68
+ plist = CFPropertyList::List.new
69
+ plist.value = CFPropertyList.guess(out)
70
+ plist.save(file, CFPropertyList::List::FORMAT_BINARY)
71
+ FileUtils.chmod(0600, file)
72
+ end
73
+
74
+ # Delete the record
75
+ # - removes the file representing the record from disk
76
+ # - will accept an alternate path than the default; useful for debugging
77
+ # - returns true if the file was destroyed or does not exist; false otherwise
78
+ def destroy(file = @file)
79
+ FileUtils.rm file if File.exists? file
80
+ !File.exists? file
81
+ end
82
+
83
+ # Test object equality
84
+ # - Class#eql? is not being passed to the delegate
85
+ # - it needs a little help
86
+ def eql?(obj)
87
+ if obj.is_a?(self.class)
88
+ return self.record.eql?(obj.record)
89
+ end
90
+ false
91
+ end
92
+ alias equal? eql?
93
+
94
+ # Diff two records
95
+ # - of limited value except for debugging
96
+ # - output is not very coherent
97
+ def diff(other)
98
+ this = self.record
99
+ other = other.record
100
+ (this.keys + other.keys).uniq.inject({}) do |memo, key|
101
+ unless this[key] == other[key]
102
+ if this[key].kind_of?(Hash) && other[key].kind_of?(Hash)
103
+ memo[key] = this[key].diff(other[key])
104
+ else
105
+ memo[key] = [this[key], other[key]]
106
+ end
107
+ end
108
+ memo
109
+ end
110
+ end
111
+
112
+ # Override the Hash getter method
113
+ # - so that we can use Symbols as well as Strings
114
+ def [](key)
115
+ key = key.to_s if key.is_a?(Symbol)
116
+ super(key)
117
+ end
118
+
119
+ # Override the Hash setter method
120
+ # - so that we can use Symbols as well as Strings
121
+ def []=(key, value)
122
+ key = key.to_s if key.is_a?(Symbol)
123
+ super(key, value)
124
+ end
125
+
126
+ private
127
+
128
+ # Synthesize a record
129
+ # - returns a composite record (Hash) compiled by merging the input data with a pre-existing matched record
130
+ # - if there is no matching record, missing attributes will be synthesized from defaults
131
+ # - returns an Hash stored in an instance variable: @composite
132
+ def synthesize(data)
133
+ @real ||= load_plist(@file)
134
+ if @real
135
+ @composite = @real.dup
136
+ @composite.merge!(data)
137
+ else
138
+ @composite = defaults(data)
139
+ end
140
+ @composite
141
+ end
142
+
143
+ # Handle required but unspecified record attributes
144
+ # - GUID is the only attribute common to all record types
145
+ def defaults(data)
146
+ next_guid = UUID.new
147
+ defaults = { 'generateduid' => ["#{next_guid}"] }
148
+ defaults.merge(data)
149
+ end
150
+
151
+ # Format the user input so it can be processed
152
+ # - input is key/value pairs
153
+ # - keys are preserved
154
+ # - values are converted to arrays to satisfy record schema
155
+ def normalize(input)
156
+ name_error = "Name attribute only supports lowercase letters, hypens, and underscrores."
157
+ # If there's only a single arg, and it's a String, make it the :name attrib
158
+ input = input.is_a?(String) ? { 'name' => input } : input
159
+ result = input.inject({}){ |memo,(k,v)| memo[k.to_s] = [v.to_s]; memo }
160
+ raise DSLocalError.new(name_error) unless result['name'].first.match /^[_a-z0-9][a-z0-9_-]*$/
161
+ result
162
+ end
163
+
164
+ # Returns the type of record being instantiated
165
+ # - derived from class of object
166
+ # - returns String
167
+ def record_type
168
+ string = self.class.to_s
169
+ parts = string.split(/::/)
170
+ parts.last.to_s.downcase
171
+ end
172
+
173
+ # Get all records of type
174
+ # - returns Array of all records for the matching type
175
+ def all_records(node)
176
+ records = []
177
+ search_base = "#{DSLOCAL_ROOT}/#{node}/#{@record_type + 's'}"
178
+ files = Dir["#{search_base}/*.plist"]
179
+ unless files.empty?
180
+ files.each do |path|
181
+ records << eval("#{self.class}.init_with_file('#{path}')")
182
+ end
183
+ end
184
+ records
185
+ end
186
+
187
+ # For a set of records, get all attributes of type
188
+ # - params are: type (Symbol) and records (Array)
189
+ # - symbol is one of the valid DSLocalRecord attribute types (ie. :uid, :gid) as Symbol
190
+ # - array is a collection of records (see DSLocalRecord#all_records)
191
+ # - parses array of records and collects attribs of type
192
+ # - returns Array
193
+ def get_all_attribs_of_type(type, records)
194
+ type = type.to_s
195
+ begin
196
+ attribs = []
197
+ unless records.empty?
198
+ records.each do |record|
199
+ attrib = record[type]
200
+ next if attrib.empty?
201
+ attribs << attrib
202
+ end
203
+ end
204
+ rescue => error
205
+ puts "Ruby Error: #{error.message}"
206
+ end
207
+ attribs
208
+ end
209
+
210
+ # Given an array of id number attributes, find the next available id number
211
+ # - params are: min (Integer) and ids (Array)
212
+ # - scans the array and delivers the next free id number
213
+ # - returns String
214
+ def next_id(min, ids)
215
+ ids.flatten!
216
+ ids.collect! { |id| id.to_i }
217
+ begin
218
+ ids.sort!.uniq!
219
+ ids.each_with_index do |id, i|
220
+ next if (id < min)
221
+ next if (id + 1 == ids[i + 1])
222
+ return (id + 1).to_s
223
+ end
224
+ rescue => error
225
+ puts "Ruby Error: #{error.message}"
226
+ end
227
+ min.to_s
228
+ end
229
+
230
+ # Provide dot notation for setting and getting valid attribs
231
+ def method_missing(meth, *args, &block)
232
+ if args.empty?
233
+ return self[meth.to_s] if self[meth.to_s]
234
+ return nil if defaults(@data)[meth.to_s]
235
+ else
236
+ if meth.to_s =~ /=$/
237
+ if self["#{$`}"] or defaults(@data)["#{$`}"]
238
+ if args.is_a? Array
239
+ return self["#{$`}"] = (args.each { |e| e.to_s }).flatten
240
+ elsif args.is_a? String
241
+ return self["#{$`}"] = e.to_s
242
+ end
243
+ end
244
+ end
245
+ end
246
+ super
247
+ end
248
+
249
+ end
250
+
251
+ end
252
+
@@ -0,0 +1,22 @@
1
+ module MacAdmin
2
+
3
+ # Computer
4
+ # - creates and manages Mac OS X Computer records
5
+ # - params: :name, :realname, :en_address
6
+ class Computer < DSLocalRecord
7
+
8
+ # Handle required but unspecified record attributes
9
+ # - generates missing attributes
10
+ # - changes are merged into the composite record
11
+ def defaults(data)
12
+ mac_address = get_primary_mac_address
13
+ defaults = {
14
+ 'realname' => ["#{data['name'].first.capitalize}"],
15
+ 'en_address' => [mac_address]
16
+ }
17
+ super defaults.merge(data)
18
+ end
19
+
20
+ end
21
+
22
+ end
@@ -0,0 +1,19 @@
1
+ module MacAdmin
2
+
3
+ # ComputerGroup
4
+ # - creates and manages AMC OS X Computer Groups
5
+ # - inherits from MacAdmin::Group
6
+ # - params: :name, :realname, :gid
7
+ class ComputerGroup < Group
8
+
9
+ MIN_GID = 501
10
+
11
+ def initialize(args)
12
+ @member_class = Computer unless defined? @member_class
13
+ @group_class = ComputerGroup unless defined? @group_class
14
+ super args
15
+ end
16
+
17
+ end
18
+
19
+ end
@@ -0,0 +1,281 @@
1
+ module MacAdmin
2
+
3
+ class DSLocalNodeError < StandardError
4
+ end
5
+
6
+ # DSLocalNode
7
+ # - constructs and manages Local OpenDirectory nodes
8
+ # - takes one parameter: name
9
+ # - if no name param is passed, 'Default' node is used
10
+ class DSLocalNode
11
+
12
+ require 'find'
13
+
14
+ SANDBOX_FILE = '/System/Library/Sandbox/Profiles/com.apple.opendirectoryd.sb'
15
+ PREFERENCES = '/Library/Preferences/OpenDirectory/Configurations/Search.plist'
16
+ PREFERENCES_LEGACY = '/Library/Preferences/DirectoryService/SearchNodeConfig.plist'
17
+ CHILD_DIRS = ['aliases', 'computer_lists', 'computergroups', 'computers', 'config', 'groups', 'networks', 'users']
18
+ DSLOCAL_ROOT = '/private/var/db/dslocal/nodes'
19
+ DIRMODE = 16832
20
+ FILEMODE = 33152
21
+ OWNER = 0
22
+ GROUP = 0
23
+
24
+ attr_reader :name, :label, :root
25
+
26
+ def initialize(name='Default')
27
+ @name = name
28
+ @label = "/Local/#{name}"
29
+ @root = "#{DSLOCAL_ROOT}/#{name}"
30
+ load_configuration_file
31
+ self
32
+ end
33
+
34
+ # Compound method: create and then activate the node
35
+ def create_and_activate
36
+ create
37
+ activate
38
+ end
39
+
40
+ # Compound method: destroy and then deactivate the node
41
+ def destroy_and_deactivate
42
+ destroy
43
+ deactivate
44
+ end
45
+
46
+ # Compound method: does the node exist and is it active?
47
+ def exists_and_active?
48
+ exists? and active?
49
+ end
50
+
51
+ # Does the directory structure exist?
52
+ def exists?
53
+ validate_directory_structure
54
+ end
55
+
56
+ # Test whether or not the node is in the search path
57
+ # - also: test the sandbox configuration if required
58
+ def active?
59
+ if needs_sandbox?
60
+ return false unless sandbox_active?
61
+ end
62
+ load_configuration_file
63
+ if self.name.eql? 'Default'
64
+ case policy = self.searchpolicy
65
+ when Integer
66
+ return true if policy < 3
67
+ else
68
+ return true if policy =~ /\AdsAttrTypeStandard:[LN]SPSearchPath\z/
69
+ end
70
+ end
71
+ return false if cspsearchpath.nil?
72
+ return false unless searchpolicy_is_custom?
73
+ cspsearchpath.member?(@label)
74
+ end
75
+
76
+ # Create the directory structure
77
+ def create
78
+ create_directories
79
+ end
80
+
81
+ # Destroy the directory structure
82
+ def destroy
83
+ FileUtils.rm_rf @root
84
+ end
85
+
86
+ # Add the node to the list of searchable directory services
87
+ # - also: add a sandbox configuration if required
88
+ def activate
89
+ activate_sandbox if needs_sandbox?
90
+ insert_node
91
+ set_custom_searchpolicy
92
+ save_config
93
+ end
94
+
95
+ # Remove the node to the list of searchable directory services
96
+ # - also: remove a sandbox configuration if required
97
+ def deactivate
98
+ deactivate_sandbox if needs_sandbox?
99
+ remove_node
100
+ save_config
101
+ end
102
+
103
+ # Returns the search policy
104
+ def searchpolicy
105
+ eval @policy_key
106
+ end
107
+
108
+ # Replaces the search policy
109
+ def searchpolicy=(val)
110
+ if val.is_a?(String)
111
+ eval @policy_key+"= val"
112
+ else
113
+ eval @policy_key+"= #{val}"
114
+ end
115
+ end
116
+
117
+ # Returns the search path array
118
+ def cspsearchpath
119
+ eval @paths_key
120
+ end
121
+
122
+ # Replaces the search path array
123
+ def cspsearchpath=(array)
124
+ eval @paths_key+"= array"
125
+ end
126
+
127
+ private
128
+
129
+ # Does this platform require a sandbox configuration?
130
+ def needs_sandbox?
131
+ MAC_OS_X_PRODUCT_VERSION > 10.7
132
+ end
133
+
134
+ # Produces a Regex for matching the OpenDirectory sandbox's "allow file-write" rules
135
+ def sb_regex(name = 'Default')
136
+ exemplar = %Q{#"^(/private)?/var/db/dslocal/nodes/Default(/|$)"}
137
+ pattern = name.eql?('Default') ? name : exemplar.sub(/Default/, name)
138
+ pattern = Regexp.escape pattern
139
+ Regexp.new pattern.gsub /\//,'\\/'
140
+ end
141
+
142
+ # Is the there an active sandbox for the node?
143
+ def sandbox_active?
144
+ if File.exists? SANDBOX_FILE
145
+ @sandbox = File.readlines(SANDBOX_FILE)
146
+ @sandbox.each { |line| return true if line.match sb_regex(@name) }
147
+ end
148
+ false
149
+ end
150
+
151
+ # Activate the node's sandbox
152
+ def activate_sandbox
153
+ unless sandbox_active?
154
+ @sandbox.each_with_index do |line, index|
155
+ if line.match sb_regex
156
+ @sandbox.insert index + 1, line.sub(/Default/, @name)
157
+ end
158
+ end
159
+ File.open(SANDBOX_FILE, 'w') { |f| f << @sandbox }
160
+ end
161
+ end
162
+
163
+ # De-activate the node's sandbox
164
+ def deactivate_sandbox
165
+ if sandbox_active?
166
+ @sandbox.delete_if do |line|
167
+ line.match sb_regex @name
168
+ end
169
+ File.open(SANDBOX_FILE, 'w') { |f| f << @sandbox }
170
+ end
171
+ end
172
+
173
+ # Insert the node into the search path immediately after any builtin local nodes
174
+ def insert_node
175
+ self.cspsearchpath ||= []
176
+ dslocal_node = '/Local/Default'
177
+ bsd_node = '/BSD/local'
178
+
179
+ unless self.cspsearchpath.include? @label
180
+ if index = cspsearchpath.index(bsd_node)
181
+ cspsearchpath.insert(index + 1, @label)
182
+ elsif index = cspsearchpath.index(dslocal_node)
183
+ cspsearchpath.insert(index + 1, @label)
184
+ else
185
+ cspsearchpath.unshift(@label)
186
+ end
187
+ end
188
+ self.cspsearchpath.uniq!
189
+ end
190
+
191
+ # Remove the node from the search path
192
+ def remove_node
193
+ cspsearchpath.delete(@label)
194
+ end
195
+
196
+ # Has custom ds searching been enabled?
197
+ def searchpolicy_is_custom?
198
+ searchpolicy.eql?(@custom)
199
+ end
200
+
201
+ # Set the search opolicy to custom
202
+ def set_custom_searchpolicy
203
+ self.searchpolicy = @custom
204
+ end
205
+
206
+ # Save the configuraton file to disk
207
+ def save_config
208
+ plist = CFPropertyList::List.new
209
+ plist.value = CFPropertyList.guess(@config)
210
+ plist.save(@file, CFPropertyList::List::FORMAT_XML)
211
+ end
212
+
213
+ # Check hierarchy and permissions and ownership are valid
214
+ # - returns bool
215
+ def validate_directory_structure
216
+ return false unless File.exists? @root
217
+ Find.find(@root) do |path|
218
+ stat = File::Stat.new path
219
+ return false unless stat.uid == OWNER and stat.gid == GROUP
220
+ if File.directory? path
221
+ return false unless stat.mode == DIRMODE
222
+ else
223
+ return false unless stat.mode == FILEMODE
224
+ end
225
+ end
226
+ true
227
+ end
228
+
229
+ # Create the dir structure for a DSLocal node
230
+ def create_directories
231
+ begin
232
+ FileUtils.mkdir_p @root unless File.exist? @root
233
+ FileUtils.chmod(0700, @root)
234
+ CHILD_DIRS.each do |child|
235
+ FileUtils.mkdir_p("#{@root}/#{child}") unless File.exist?("#{@root}/#{child}")
236
+ FileUtils.chmod(0700, "#{@root}/#{child}")
237
+ end
238
+ FileUtils.chown_R(OWNER, GROUP, @root)
239
+ rescue Exception => e
240
+ p e.message
241
+ p e.backtrace.inspect
242
+ end
243
+ end
244
+
245
+ # Decide which configuration file we should be trying to access
246
+ def get_configuration_file
247
+ file = PREFERENCES_LEGACY
248
+ file = PREFERENCES if File.exists? '/usr/libexec/opendirectoryd'
249
+ file
250
+ end
251
+
252
+ # If the file we need is still not on disk, we HUP the dir service
253
+ # Try 3 times, and then fail
254
+ def load_configuration_file
255
+ 3.times do
256
+ @file = get_configuration_file
257
+ if File.exists? @file
258
+ break
259
+ else
260
+ restart_directoryservice(11)
261
+ end
262
+ end
263
+ raise DSLocalNodeError "Cannot read the Search policy file, #{@file}" unless File.exists?(@file)
264
+ @config = load_plist @file
265
+ # Setup some configuration key paths that can be evaluated and plugged into
266
+ # the standard methods. Which paths are used is based on which config file
267
+ # we are working with.
268
+ if @config['modules']
269
+ @paths_key = %q{@config['modules']['session'][0]['options']['dsAttrTypeStandard:CSPSearchPath']}
270
+ @policy_key = %q{@config['modules']['session'][0]['options']['dsAttrTypeStandard:SearchPolicy']}
271
+ @custom = 'dsAttrTypeStandard:CSPSearchPath'
272
+ else
273
+ @paths_key = %q{@config['Search Node Custom Path Array']}
274
+ @policy_key = %q{@config['Search Policy']}
275
+ @custom = 3
276
+ end
277
+ end
278
+
279
+ end
280
+
281
+ end