synchronize-shares   [plain text]


#! /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

# Take an exclusive lock on ourselves. This is a mutex to serialise
# simultaneous invocations.
mutex = File.open($0, 'r')
mutex.flock(File::LOCK_EX)

$0 = File.basename($0)

$GUEST = false
$VERBOSE = false

# 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

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 :