#! /usr/bin/env -i /usr/bin/ruby ## # Copyright (C) 2007 Apple Inc. All rights reserved. # # @APPLE_LICENSE_HEADER_START@ # # This file contains Original Code and/or Modifications of Original Code # as defined in and that are subject to the Apple Public Source License # Version 2.0 (the 'License'). You may not use this file except in # compliance with the License. Please obtain a copy of the License at # http://www.opensource.apple.com/apsl/ and read it before using this # file. # # The Original Code and all software distributed under the License are # distributed on an 'AS IS' basis, WITHOUT WARRANTY OF ANY KIND, EITHER # EXPRESS OR IMPLIED, AND APPLE HEREBY DISCLAIMS ALL SUCH WARRANTIES, # INCLUDING WITHOUT LIMITATION, ANY WARRANTIES OF MERCHANTABILITY, # FITNESS FOR A PARTICULAR PURPOSE, QUIET ENJOYMENT OR NON-INFRINGEMENT. # Please see the License for the specific language governing rights and # limitations under the License. # # @APPLE_LICENSE_HEADER_END@ ## ENV.clear ENV['__CF_USER_TEXT_ENCODING'] = "0x#{Process::Sys::getuid()}:0:0" require 'osx/foundation'; require 'optparse'; require 'open3' # NOTE Remember that this script is generally being called by either launchd or # some admin tool. This means that we must implement a best-effort service # model and never fail to do as much as possible. There will be no error output # unless the --verbose flag is specified. ENV.clear $0 = File.basename($0) $GUEST = false $VERBOSE = false $READONLY = false # Take an exclusive lock to serialise simultaneous invocations. begin mutex = File.open('/var/samba/shares.mutex', 'r') if (!mutex.flock(File::LOCK_EX)) $READONLY = true end rescue # Most likely got EPERM opening the mutex, meaning we are running # unprivileged. $READONLY = true end # This class lets us run commands without invoking the shell. We are not to # concerned with trapping the error status, since we are implementing a # best-effort service model. It's nice to keep stdout and stderr separate # though. class ShellCommand DSCL = '/usr/bin/dscl' NET = '/usr/bin/net' def ShellCommand.run(*cmd) # This ends up in Kernel.exec, which invokes the shell when passed a # single argument. return false unless cmd.length > 1 # A final layer of paranoia. return false unless (cmd[0] == DSCL || cmd[0] == NET) print "#{$0}: running command: '#{cmd.join("' '")}'\n" if $VERBOSE; io = Open3.popen3(*cmd) { | stdin, stdout, stderr | stdin.close # we are not going to provide any input loop do read_array = [stdout, stderr].reject { |fd| fd.closed? } break if read_array.empty? ready = Kernel.select(read_array, nil, nil, 0.1) next if ready == nil ready.flatten.each { | fd | begin line = fd.readline rescue # Reading at EOF will throw EOFError, but any exception # exception is enough to know we are done. fd.close next end if (fd == stderr) $stderr.print "#{$0} (#{cmd[0]}): #{line}" \ if $VERBOSE else if block_given? yield line else $stdout.print line \ if $VERBOSE end end } end } end end class NotificationCenter def initialize @center = OSX::NSDistributedNotificationCenter.defaultCenter() end def post(notification, info) @center.postNotificationName_object_userInfo_options( notification, # notification name nil, # notification sender, info, # user info dictionary OSX::NSNotificationPostToAllSessions); end end class SharePoints # Emit the SharePoints configuration in smb.conf format. def smbconf(sharename) return nil unless @shares.has_key?(sharename) strval = "[#{sharename}]\n" @shares[sharename].keys.sort.each { | key | confstr = UserShares.mapattr(key, @shares[sharename][key]) next unless confstr strval += "\t#{confstr}\n" } return strval end def initialize @shares = {} # Hash of hashes indexed by the share name share = {} data = '' ShellCommand.run(ShellCommand::DSCL, '-plist', '.', '-readall', '/SharePoints') { |line| data += line } cfdata = OSX::NSData.dataWithBytes_length(data, data.length) plist, format, err = \ OSX::NSPropertyListSerialization.propertyListFromData_mutabilityOption_format_errorDescription(cfdata, OSX::NSPropertyListImmutable) if (plist == nil or !plist.kind_of? OSX::NSCFArray) $stderr.print "#{$0}: failed to parse dscl plist\n" \ if $VERBOSE return nil end # OK. No we have an array of dictionaries, where each dictionary is # a share definition. plist.each { | entry | s = SharePoints.to_native(entry) if s.has_key?('dsAttrTypeNative:smb_name') name = s['dsAttrTypeNative:smb_name'] @shares[name] = s else $stderr.print "#{$0}: ignoring share with missing smb_name\n" \ if $VERBOSE end } end def each_key @shares.each_key { | key | yield key } end def each @shares.each { | key, hash | yield key, hash } end private def SharePoints.to_native(val) return nil if val == nil if val.kind_of? OSX::NSCFBoolean return (val == OSX::KCFBooleanTrue ? true : false) end if val.kind_of? OSX::NSCFString return val.to_s end if val.kind_of? OSX::NSCFNumber return val.to_i end if val.kind_of? OSX::NSCFArray array = [] val.each { |element| array += [ SharePoints.to_native(element) ] } # The plist emitted by dscl is unusual in that each value in a # key-value pair is emitted as an array containing a simgle # element. So here we squash single-element arrays to their value. case array.length when 0 return nil when 1 return array[0] else return array end end if val.kind_of? OSX::NSCFDictionary hash = {} val.allKeys().each { | key | # Note: we need to convert both the key and the data, # otherwise we will end up indexed by OSX::NSCFString and # won't be able to index by Ruby Strings. new_key = SharePoints.to_native(key) new_val = SharePoints.to_native(val[key]) hash[new_key] = new_val } return hash end # NOTE: We don't convert CFData or CFDate because we # don't need them for the preferences we have. $stderr.print \ "#{$0}: preferences type #{val.class} is not supported\n" \ if $VERBOSE return nil end end class UserShares # Map DS attributes to smb.conf keys. The required attributes are # commented out because they need special handling. ATTRIBUTE_MAP = { 'dsAttrTypeNative:directory_path' => 'path', 'dsAttrTypeNative:name' => 'comment', 'dsAttrTypeNative:smb_guestaccess' => 'guest ok', 'dsAttrTypeNative:smb_inherit_permissions' => 'inherit permissions', 'dsAttrTypeNative:smb_createmask' => 'create mask', 'dsAttrTypeNative:smb_directorymask' => 'directory mask', 'dsAttrTypeNative:smb_oplocks' => 'oplocks', 'dsAttrTypeNative:smb_strictlocking' => 'strict locking', } def UserShares.mapattr(key, value) return nil unless ATTRIBUTE_MAP.has_key?(key) # Map boolean values to standard smb.conf names case value when '1' val = 'yes' when '0' val = 'no' else val = value end return "#{ATTRIBUTE_MAP[key]}=#{val}" end def initialize @shares = [] # 'net usershare list --long' prints the names of each usershare, one # per line. ShellCommand.run(ShellCommand::NET, 'usershare', 'list', '--long') { | line | if line =~ /^\s*(.+)\s*$/ # share named should always be valid, otherwise the # usershare system would not have accepted thenm. sharename = $1 @shares.push(sharename) end } @shares = @shares.sort end # Emit the UserShares configuration in smb.conf format. def smbconf(sharename) return nil unless @shares.include?(sharename) strval = "" ShellCommand.run(ShellCommand::NET, 'usershare', 'info', '--long', sharename) { |line| if line =~ /\[.+\]/ strval += "#{line}" else # Indent share parameters and insert spaces around '='. line = line.sub(/([^[:space:]])=([^[:space:]])/, '\1 = \2') strval += "\t#{line}" end } return strval end # We iterate by share names. def each @shares.each { | sharename | yield sharename } end # Return true if the gives hash has the def validate(sharehash) invalid = Regexp.new('[%<>*?|\/\\+=;:\$",]') # Check for required attributes. return false unless ( sharehash.has_key?('dsAttrTypeNative:smb_shared') && sharehash.has_key?('dsAttrTypeNative:smb_name') && sharehash.has_key?('dsAttrTypeNative:directory_path') ) # Check we have a legal share name. return false if (sharehash['dsAttrTypeNative:smb_name'] =~ invalid) return true end # Remove all the usershare records. def clear self.each { | sharename | ShellCommand.run(ShellCommand::NET, 'usershare', 'delete', sharename); } end # Create a new usershare record. def store(sharename, sharehash) unless validate(sharehash) print "#{$0}: invalid share [#{sharename}] \n" if $VERBOSE return false end if sharehash['dsAttrTypeNative:smb_shared'] != '1' print "#{$0}: share [#{sharename}] is disabled\n" if $VERBOSE return end path = sharehash['dsAttrTypeNative:directory_path'] name = sharehash['dsAttrTypeNative:smb_name'] comment = sharehash['dsAttrTypeNative:smb_name'] if name != sharename # If this happens, we have a bug. raise ArgumentError, "inconsistent share #{sharename}", caller end # No share ACL by default. We rely on filesystem access control. # S-1-1-0 is the group "everyone" - we can't use a group name because # we can't rely on smbd being available to resolve the name to a SID. acl = 'S-1-1-0:F' # Enable guest access according to the global default, but override # with the per-share value if it is set. if sharehash.has_key? 'dsAttrTypeNative:smb_guestaccess' if sharehash['dsAttrTypeNative:smb_guestaccess'] == '1' guest = 'guest_ok=y' else guest = 'guest_ok=n' end else guest = $GUEST ? 'guest_ok=y' : 'guest_ok=n' end args = [ 'usershare', 'add', name, path, comment, acl, guest] sharehash.each { |key, value| # Skip attributes that we already mapped to commandline options. case key when 'dsAttrTypeNative:directory_path' next when 'dsAttrTypeNative:name' next when 'dsAttrTypeNative:smb_guestaccess' next end argval = UserShares.mapattr(key, value) next unless argval args.push(argval) if argval } ShellCommand.run(ShellCommand::NET, *args) return true end end opts = OptionParser.new opts.on('--verbose', 'print extra debugging messages') { $VERBOSE = true } opts.on('--enable-guest', 'enable guest access by default') { $GUEST = true } opts.on('--list-current', 'print the current share configuration') { ushares = UserShares.new() ushares.each { | sharename | $stdout.print ushares.smbconf(sharename) } exit 0 } opts.on('--list-pending', 'print the pending share configuration') { dshares = SharePoints.new() dshares.each_key { | sharename | $stdout.print dshares.smbconf(sharename) } exit 0 } begin opts.parse!(ARGV) # Remove args as they are parsed. if (ARGV.length != 0) raise OptionParser::InvalidOption, ARGV[0], caller end rescue OptionParser::InvalidOption => err $stderr.print "#{$0}: #{err}\n" $stderr.print opts.help() exit 1 end if $READONLY exit 1 end dshares = SharePoints.new() ushares = UserShares.new() # Synchronize the shares from DS to usershares. ushares.clear() dshares.each { | sharename, sharehash | ushares.store(sharename, sharehash) } # Notify anyone who cares. notify = NotificationCenter.new() notify.post('com.apple.ServiceConfigurationChangedNotification', { 'ServiceName' => 'sharepoints'}); # vim: set ft=ruby sts=4 ts=8 tw=79 :