macadmin 0.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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