synchronize-preferences   [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'
require 'ipaddr'

# 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.

$VERBOSE = false    # Print trace messages
$LINGER = 0	    # Wait for more preferences updates
$RESTART = false    # Force service restart
$DEBUG = false	    # Print extra debugging statements

$ID = '$Id: synchronize-preferences 32597 2007-07-22 20:51:18Z jpeach $'

# 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)

OSX.require_framework 'SystemConfiguration'

# Wrapper class to access SCPreferences API.
class Preferences
    def initialize(appid)
	# Unlike the CFPreferences API, SCPreferences requires the actual
	# plist filename, which always end in '.plist'.
	appid = "#{appid}.plist" unless appid =~ /\.plist$/

	@prefs = OSX::SCPreferencesCreate(nil, $0, appid)
	@keys = Preferences.to_native(OSX::SCPreferencesCopyKeyList(@prefs))
	@keys.push('PreferencesSignature')

	$stderr.print \
	    "SCPreferences (appid=#{appid}) keys: #{@keys.join(",")}\n" \
	    if $DEBUG

    end

    def each
	keys.each { | key |
	    yield key, self[key]
	}
    end

    def signature
	sig = OSX::SCPreferencesGetSignature(@prefs)

	# Converting CFData to a string ends up with something that looks like
	# this: <0500000e 4a5e0f00 e8e24046 00000000 f8000000 00000000>
	# We strip the angle brackets and spaces to give a plain hex string.
	return sig.to_s.gsub(/[ <>]/, '')
    end

    def has_key?(key)
	return @keys.include?(key)
    end

    def [](key)
	case key
	when 'PreferencesSignature'
	    return self.signature
	else
	    val = OSX::SCPreferencesGetValue(@prefs, key)

	    # Need to convert to a native Ruby type because we merge these
	    # values with our default set, which are native types.
	    return (Preferences.to_native(val) rescue nil)
	end
    end

    # Load a preferences hash from a plist.
    def Preferences.load_plist(path)
	print "#{$0}: loading #{path}\n" if $VERBOSE
	data = OSX::NSData.dataWithContentsOfFile(path)
	return nil unless data

	plist, format, err = OSX::NSPropertyListSerialization.propertyListFromData_mutabilityOption_format_errorDescription(data,
		OSX::NSPropertyListImmutable)

	if (plist == nil or !plist.kind_of? OSX::NSCFDictionary)
	    return nil
	end

	return Preferences.to_native(plist)
    end

    # Convert a CFPropertyListRef to a native Ruby type.
    def Preferences.to_native(val)

	return nil if val == nil

	$stderr.print "converting (#{val.class})\n" if $DEBUG

	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 += [ Preferences.to_native(element) ] }

	    return array
	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 = Preferences.to_native(key)
		new_val = Preferences.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

# 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
    LAUNCHCTL = '/bin/launchctl'
    SW_VERS = '/usr/bin/sw_vers'

    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] == LAUNCHCTL || cmd[0] == SW_VERS)

	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.eof? }
                break if read_array.empty?

                ready = Kernel.select(read_array, nil, nil, 0.1)
		next if ready == nil

                ready.flatten.each { | fd |
		    line = (fd.readline rescue nil)
		    next if line == nil

                    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 LaunchControl
    SMBD = {
	'service' => 'org.samba.smbd',
	'plist' => '/System/Library/LaunchDaemons/smbd.plist',
	'enabled' => false,
	'required' => false
    }

    WINBINDD = {
	'service' => 'org.samba.winbindd',
	'plist' => '/System/Library/LaunchDaemons/org.samba.winbindd.plist',
	'enabled' => false,
	'required' => true # We always want winbindd running
    }

    NMBD = {
	'service' => 'org.samba.nmbd',
	'plist' => '/System/Library/LaunchDaemons/nmbd.plist',
	'enabled' => false,
	'required' => false
    }

    @@force_restart = false # True is we should forcibly bounce all services
    @@force_disable = false # True if we should always disable all services
    @@config_change = true  # False if the preferences didn't actually change

    def LaunchControl.force_restart
	@@force_restart = true
    end

    def LaunchControl.force_disable
	@@force_disable = true
    end

    # Mark this configuration as unchanged. Unless we are forcing something,
    # we can assume that there's no need to restart any services
    def LaunchControl.no_config_change
	@@config_change = false
    end

    def LaunchControl.init
	ShellCommand.run(ShellCommand::LAUNCHCTL, 'list') { |line|
	    # List format is 3 fields: PID Status Label
	    fields = line.split(/\s+/)
	    next if fields.length != 3

	    case fields[2]
	    when SMBD['service']
		SMBD['enabled'] = true
	    when WINBINDD['service']
		WINBINDD['enabled'] = true
	    when NMBD['service']
		NMBD['enabled'] = true
	    end
	}
    end

    def LaunchControl.require(service)
	print "#{$0}: requiring service #{service['service']}\n" \
	    if $VERBOSE

	case service
	when SMBD
	    SMBD['required'] = true
	when WINBINDD
	    WINBINDD['required'] = true
	when NMBD
	    NMBD['required'] = true
	end
    end

    # XXX if the netbios name changed, we should always restart nmbd.
    # Actually, it is always safe to restart nmbd, it's only winbindd and smbd
    # that we need to worry about.
    def LaunchControl.sync

	print "#{$0}: syncing, config_change=#{@@config_change}, " +
	    "force_restart=#{@@force_restart}, " +
	    "force_disable=#{@@force_disable}\n" \
	    if $VERBOSE

	# Now we are going to restart all the necessary services. It's really
	# too difficult to figure out all the parameters could be changed
	# without requiring a restart. We should not spuriously end up here
	# too often because we know when the preferences changed, which
	# protects us from being called multiple times to update the same info.
	[SMBD, WINBINDD, NMBD].each { |service|

	    if $VERBOSE
		    print "#{$0}: service #{service['service']} "
		    print "required=#{service['required']} "
		    print "enabled=#{service['enabled']}\n"
	    end

	    # We need to leave all the services disabled.
	    if @@force_disable
		launchd_disable(service['plist'])
		next
	    end

	    if service['required']

		if service['enabled']
		    # Service already enabled. Restart it if necessary.
		    if @@config_change || @@force_restart
			print "#{$0}: restarting #{service['service']}\n" \
			    if $VERBOSE
			launchd_stop(service['service'])
		    end
		else
		    launchd_enable(service['plist'])
		end
	    else
		if service['enabled']
		    launchd_disable(service['plist'])
		end
	    end
	}

	notify = NotificationCenter.new()
	notify.post('com.apple.ServiceConfigurationChangedNotification',
	    {
		'ServiceName' => 'smb',
		'State' => (SMBD['enabled'] ? 'RUNNING' : 'STOPPED') 
	    })
    end

private

    def LaunchControl.launchd_stop(service)
	ShellCommand.run(ShellCommand::LAUNCHCTL, 'stop', service)
    end

    def LaunchControl.launchd_enable(plist)
	ShellCommand.run(ShellCommand::LAUNCHCTL, 'load', '-w', plist)
    end

    def LaunchControl.launchd_disable(plist)
	ShellCommand.run(ShellCommand::LAUNCHCTL, 'unload', '-w', plist)
    end

end

# Handle options which we can't or don't want to implement.
class NullOption
    def initialize(name)
	@name = name
    end

    def emit(prefs, config)
	print "#{$0}: ignoring unimplemented option '#{@name}'\n" if $VERBOSE
    end

    def to_s
	return @name
    end
end

class SuspendOption
    def initialize(name)
	@name = name
    end

    def emit(prefs, config)
	if prefs[@name]
	    LaunchControl.force_disable
	end
    end
end

# Handle options that have a simple 1-1 mapping to smb.conf options.
class SimpleOption < NullOption
    def initialize(name, text)
	@name = name
	@text = text
    end

    def emit(prefs, config)
	# It's possible (in principle) that setting a value to empty might
	# be different to not setting it at all. I don't know of any such
	# cases, so let's avoid this for now -- jpeach.
	return unless self.has_value?(prefs)

	if (prefs[@name].class == TrueClass || prefs[@name].class == FalseClass)
	    val = prefs[@name] ? 'yes' : 'no'
	elsif prefs[@name].class == String
	    val = prefs[@name]
	elsif prefs[@name].class == Fixnum
	    val = "#{prefs[@name]}"
	else
	    # SimpleOption is only for simple options!
	    $stderr.print \
		"#{$0}: the #{@name} preference is an invalid type (#{prefs[@name].class})\n" \
		if $VERBOSE
	    return
	end

	config.append(@text, val)
    end

    def has_value?(prefs)
	val = prefs[@name]

	return false if val == nil
	return false if val.kind_of?(String) && val == ""
	return false if val.kind_of?(Array) && val.length == 0

	return true
    end

end

# Handle setting the Kerberos realm (both the managed and local).
class KerberosRealmOption < NullOption
    def initialize(realm_tag, local_realm_tag)
	@name = realm_tag
	@mrealm_tag = realm_tag
	@lrealm_tag = local_realm_tag
    end

    def emit(prefs, config)
	mrealm = SimpleOption.new(@mrealm_tag, 'realm')
	lrealm = SimpleOption.new(@lrealm_tag, 'com.apple: lkdc realm')

	# Just bail immediately if neither the managed nor local realm is
	# configured.
	return unless (mrealm.has_value?(prefs) || lrealm.has_value?(prefs))

	mrealm.emit(prefs, config)
	lrealm.emit(prefs, config)
	config.append('use kerberos keytab', 'yes')

	# If we have a local realm, but no managed realm, set both to the
	# local realm.
	if lrealm.has_value?(prefs) && !mrealm.has_value?(prefs)
	    SimpleOption.new(@lrealm_tag, 'realm').emit(prefs, config)
	end

    end
end

# Handle the case where we want to register our name with a WINS server. We
# take a bool that indicates whether we want to register and a list of IP
# addresses for the servers we will register with.
class WinsRegisterOption < NullOption
    def initialize(register_tag, addrlist_tag)
	@name = register_tag
	@addrlist = addrlist_tag
    end

    def emit(prefs, config)
	return unless prefs[@name]

	# We know we want to register, so check for WINS server addresses. The
	# order of operations here matters, because we can throw an exception.
	list = IPAddressOption.new(@addrlist, 'wins server')
	list.emit(prefs, config)

	LaunchControl.require(LaunchControl::NMBD)
    end
end

# Configure whether guest access is allowed globally. If this is enabled, the
# per-share guest settings will be respected. If this is disabled, then no
# guest for you, even if it is enabled on a share.
class GuestAccessOption < NullOption
    def initialize(pref)
	@name = pref
    end

    def emit(prefs, config)
	if self.enabled(prefs)
	    config.append('map to guest', 'Bad User')
	else
	    config.append('map to guest', 'Never')
	end

	config.append_section(SmbConfigFile::STATUS,
	    'Guest access', self.enabled(prefs) ? 'per-share' : 'never')
    end

    def enabled(prefs)
	return prefs[@name] ? true : false
    end
end

class ServicesOption < NullOption
    WINS = 'wins'
    DISK = 'disk'
    PRINT = 'print'

    def initialize(pref)
	@name = pref
    end

    def emit(prefs, config)

	disk = false
	print = false
	wins = false
	prefs[@name] = [] unless prefs[@name] != nil

	begin
	    # If the preferences file is messed up, prefs[@name] might not
	    # be an array or it might be missing, hence the exception handler
	    prefs[@name].each { |service|
		case
		when service.casecmp(WINS) == 0
		    wins = true
		    LaunchControl.require(LaunchControl::NMBD)
		when service.casecmp(DISK) == 0
		    disk = true
		    LaunchControl.require(LaunchControl::NMBD)
		    LaunchControl.require(LaunchControl::SMBD)
		when service.casecmp(PRINT) == 0
		    print = true
		    LaunchControl.require(LaunchControl::NMBD)
		    LaunchControl.require(LaunchControl::SMBD)
		end
	    }

	rescue StandardError => err
	    if $VERBOSE
		$stderr.print "#{$0}: #{err}\n"
		$stderr.print \
		    "#{$0}: the #{@name} preference is missing or invalid\n"
	    end
	end

	# Tell nmbd to act as a WINS server.
	config.append('wins support', wins ? 'yes' : 'no')

	config.append('enable disk services', disk ? 'yes' : 'no')
	config.append('enable print services', print ? 'yes' : 'no')
    end
end

# Handle automatic shares. The two cases we care about are where the logged-in
# user is shown their own home directory and where members of the "admin" group
# are shown all local volumes.
class AutoSharesOption < NullOption
    def initialize(homes_tag, admin_tag)
	super("#{homes_tag} or #{admin_tag}")
	@homes = homes_tag
	@admin = admin_tag
    end

    def emit(prefs, config)
	# XXX This is dubious. We should have a way to enable virtual admin
	# shares without also enabling virtual home shares.
	need_homes = (prefs[@homes] || prefs[@admin])

	if need_homes
	    config.append_section(SmbConfigFile::HOMES,
		    'comment', 'User Home Directories')
	    config.append_section(SmbConfigFile::HOMES,
		    'browseable', 'no')
	    config.append_section(SmbConfigFile::HOMES,
		    'read only', 'no')
	    config.append_section(SmbConfigFile::HOMES,
		    'create mode', '0750')
	    config.append_section(SmbConfigFile::HOMES,
		    'guest ok', 'no')
	end

	if prefs[@admin]
	    config.append_section(SmbConfigFile::HOMES,
		    'com.apple: show admin all volumes', prefs[@admin])
	end

    end
end

class IPAddressOption < NullOption
    def initialize(name, text)
	@name = name
	@text = text
    end

    def emit(prefs, config)
	return unless prefs[@name]

	addresses = []

	# Convert the strings to IP addresses just to validate the syntax
	prefs[@name].each { | addr |
	    ip = (IPAddr.new(addr, Socket::AF_INET) rescue nil)

	    unless ip
		$stderr.print \
		    "#{$0} (#{@name}): #{addr} is not a valid IPv4 address\n" \
		    if $VERBOSE
		next
	    end

	    addresses += [ addr ]
	}

	config.append(@text, addresses.join(' ')) if addresses.length > 0
    end
end

# Handle the NetBIOS browse master configuration. Depending on the server role,
# we will attempt to become with a domain or a master browser.
class MasterBrowserOption < NullOption

    LocalMaster = 'local'
    DomainMaster = 'domain'

    def initialize(name, role)
	@name = name
	@smbrole = role
    end

    def emit(prefs, config)
	val = prefs[@name] ? prefs[@name] : 'none'

	case @smbrole.role(prefs)
	when ServerRoleOption::PDC
	    # If we are a PDC, we really have to be a master browser.
	    val = DomainMaster
	when ServerRoleOption::BDC
	    # If we are a BDC, we really have to be a local browser.
	    val = LocalMaster
	end

	case 
	when val.casecmp(DomainMaster) == 0
	    config.append('domain master', 'yes')
	    config.append('preferred master', 'yes')
	    config.append('os level', '65')
	    config.append_section(SmbConfigFile::STATUS,
		'NetBIOS browsing', 'domain master browser')
	
	when val.casecmp(LocalMaster) == 0
	    config.append('domain master', 'no')
	    config.append('local master', 'yes')
	    config.append('preferred master', 'yes')
	    config.append('os level', '65')
	    config.append_section(SmbConfigFile::STATUS,
		'NetBIOS browsing', 'local master browser')
	else
	    config.append('domain master', 'no')
	    config.append('local master', 'no')
	    config.append('preferred master', 'no')
	    config.append_section(SmbConfigFile::STATUS,
		'NetBIOS browsing', 'not a master browser')
	    return
	end

	# Yes, to be a master browser, we need to be running both smbdand
	# and nmbd.
	LaunchControl.require(LaunchControl::NMBD)
	LaunchControl.require(LaunchControl::SMBD)
    end
end

class ServerRoleOption < NullOption
    STANDALONE = 'Standalone'
    ADS = 'ActiveDirectoryMember'
    PDC = 'PrimaryDomainController'
    BDC = 'BackupDomainController'
    DOMAIN = 'DomainMember'

    MACHINE_SCRIPT = '/usr/bin/opendirectorypdbconfig ' +
		'-c create_computer_account -r %u -n "/LDAPv3/127.0.0.1"'
    USER_SCRIPT = '/usr/bin/opendirectorypdbconfig ' +
		'-c create_user_account -r %u -n "/LDAPv3/127.0.0.1"'

    def initialize(name, guest)
	@name = name
	@guestopt = guest
    end

    # Return the configured role. STANDALONE is the default in the case of an
    # invalid or missing role.
    def role(prefs)
	current_role = prefs[@name]
	return STANDALONE unless current_role

	case
	when current_role.casecmp(DOMAIN) == 0
	    return DOMAIN
	when current_role.casecmp(ADS) == 0
	    return ADS
	when current_role.casecmp(PDC) == 0
	    return PDC
	when current_role.casecmp(BDC) == 0
	    return BDC
	else
	    return STANDALONE
	end
    end

    def emit(prefs, config)
	auth = @guestopt.enabled(prefs) ? "guest odsam" : "odsam";

	case self.role(prefs)
	# STANDALONE and ADS are the same except for the security mode.
	when STANDALONE
	    config.append('security', 'USER')
	    config.append('use spnego', 'yes')

	# Member of an AD domain.
	when ADS
	    config.append('security', 'ADS')
	    config.append('use spnego', 'yes')

	# Member of an NT4 domain.
	when DOMAIN
	    config.append('security', 'DOMAIN')
	    config.append('domain logons', 'no')
	    auth = @guestopt.enabled(prefs) ? "guest ntdomain odsam" \
					    : "ntdomain odsam"

	# PDC of an NT4 domain.
	when PDC
	    config.append('security', 'USER')
	    config.append('add machine script', MACHINE_SCRIPT)
	    config.append('add user script', USER_SCRIPT)
	    add_domain_logon(config)

	# BDC of an NT4 domain.
	when BDC
	    config.append('security', 'USER')
	    add_domain_logon(config)
	end

	config.append('auth methods', auth)
	config.append_section(SmbConfigFile::STATUS,
		'Server role', self.role(prefs))

    end

    # Add the parameters we need to for domain logons.
    def add_domain_logon(config)
	config.append('domain logons', 'yes')

	# If we are doing user logons, autocreate a home directory.
	config.append_section(SmbConfigFile::HOMES, 
		    'root preexec', '/usr/sbin/inituser %U')
	config.append('logon drive', 'H:')
	config.append('logon path', '\\%N\profiles%u')

	# Add a [netlogon] share.
	config.append_section(SmbConfigFile::NETLOGON, 'path', '/etc/netlogon')
	config.append_section(SmbConfigFile::NETLOGON, 'browseable', 'no')
	config.append_section(SmbConfigFile::NETLOGON, 'write list', '@admin')
	config.append_section(SmbConfigFile::NETLOGON, 'oplocks', 'yes')
	config.append_section(SmbConfigFile::NETLOGON, 'strict locking', 'no')

	# Add a [profiles] share.
	config.append_section(SmbConfigFile::PROFILES,
		'path', '/Users/Profiles')
	config.append_section(SmbConfigFile::NETLOGON, 'browseable', 'no')
	config.append_section(SmbConfigFile::NETLOGON, 'read only', 'no')
	config.append_section(SmbConfigFile::NETLOGON, 'oplocks', 'yes')
	config.append_section(SmbConfigFile::NETLOGON, 'strict locking', 'no')
    end
end

# Determine whether the preferences have changed since the last time we
# synchronized.
class PrefsChangedOption < NullOption
    @@force_change = false

    def PrefsChangedOption.force_change
	@@force_change = true
    end

    def initialize(name, path)
	@name = name
	@path = path
	@prev_sig, @prev_gen = get_config_signatures(@path)
    end

    def emit(prefs, config)
	LaunchControl.no_config_change unless self.changed(prefs)

	if prefs[@name]
	    config.append_section(SmbConfigFile::STATUS, 
		'Preferences signature',  prefs[@name])
	end

	# Stash the svn Id keyword so we can tell which script version
	# generated this configuration.
	config.append_section(SmbConfigFile::STATUS, 
		'Preferences generator',  $ID)
    end

    # Return true if the preferences have changed (ie. we need to regenerate
    # the SMB configuration).
    def changed(prefs)
	curr_sig = prefs[@name]

	if @@force_change
	    return true
	end

	if curr_sig == nil || curr_sig != @prev_sig
	    # Preference signature didn't match, don't bother checking the Id.
	    return true
	end

	if @prev_gen == nil || @prev_gen != $ID
	    # This config was generated by a different version of this script.
	    # We should be regenerating it because the configuration rules
	    # might have changed.
	    return true
	end

	return false
    end


private
    def get_config_signatures(path)
	signature = nil
	generator = nil
	begin
	    File.open(path, 'r') { | fd |
		fd.each_line { | line |
		    if line.match(/Preferences signature: ([abcdef0-9]+)/)
			signature = $1
		    end

		    if line.match(/Preferences generator: (\$Id*\$)/)
			generator = $1
		    end

		    if (signature && generator)
			return [signature, generator]
		    end
		}

	    }

	    return [signature, generator]
	rescue Exception => err
	    if $VERBOSE
		$stderr.print "#{$0}: unable to read signatures from #{path}\n"
		$stderr.print "#{$0}: #{err}\n"
	    end
	end

	return [nil, nil]
    end

end

class SmbConfigFile
    SmbConfigPath = '/etc/smb.conf'
    SmbRunConfigPath = '/var/run/smb.conf'

    GLOBAL = 'global'
    PRINTERS = 'printers'
    HOMES = 'homes'
    NETLOGON = 'netlogon'
    PROFILES = 'profiles'
    STATUS = '_internal_only_'

    def initialize()
	# @config is a hash where the keys are section labels and the 
	# values are a list of the key/value pairs in that section.
	@config = {
	    GLOBAL => {},
	    HOMES => {},
	    NETLOGON => {},
	    PROFILES => {},
	    PRINTERS => {},
	    STATUS => {}
	}
    end

    def sections
	return @config.keys
    end

    def lines(section)
	unless @config.has_key? section
	    raise ArgumentError, "invalid config section #{section}", caller
	end

	@config[section].keys.sort.each { | key |
	    yield key, @config[section][key]
	}
    end

    def has_section?(section)
	unless @config.has_key? section
	    raise ArgumentError, "invalid config section #{section}", caller
	end

	return @config[section].length > 0 ? true : false
    end

    def append(key, value)
	append_section(GLOBAL, key, value)
    end

    def append_section(section, key, value)
	unless @config.has_key? section
	    raise ArgumentError, "invalid config section #{section}", caller
	end

	@config[section][key.downcase] = value
    end
end

class SmbPreferences
    SERVER = 1
    DESKTOP = 2
    AppID = 'com.apple.smb.server'
    DefaultPrefsPath = \
	'/System/Library/CoreServices/SmbFileServer.bundle/Resources'

    ServerDefaultPrefs = "#{DefaultPrefsPath}/ServerDefaults.plist"
    DesktopDefaultPrefs = "#{DefaultPrefsPath}/DesktopDefaults.plist"

    # Builtin preferences to be used when neither the Server nor the Desktop
    # defaults can be found.
    BUILTIN = 
    {
	'NetBIOSName' => nil,
	'NetBIOSNodeType' => nil,
	'NetBIOSScope' => nil,
	'WINSServerAddressList' => [],
	'Workgroup' => 'WORKGROUP',
	'KerberosRealm' => nil,
	'LocalKerberosRealm' => nil,
	'SuspendServices' => false,
	'EnabledServices' => [],
	'ServerRole' => ServerRoleOption::STANDALONE,
	'ServerDescription' => 'Mac OS X',
	'AllowGuestAccess' => false,
	'MaxClients' => 10 ,
	'AllowKerberosAuth' => true,
	'AllowNTLMAuth'	=> true,
	'AllowLanManAuth' => false,
	'LoggingLevel' => 1,
	'DOSCodePage' => '437',
	'MasterBrowser'	=> false,
	'RegisterWINSName' => false,
	'VirtualHomeShares' => true,
	'VirtualAdminShares' => true,
	'PasswordServer' => nil,
	'PreferencesSignature' => nil,
    }

    def SmbPreferences.system_type
	is_server = false
	ShellCommand.run(ShellCommand::SW_VERS, '-productName') { |line|
	    is_server = true if line =~ /Server/
	}

	return (is_server ? SERVER : DESKTOP)
    end

    def SmbPreferences.defaults
	prefs = {}

	case system_type()
	when SERVER
	    prefs = Preferences.load_plist(ServerDefaultPrefs)
	when DESKTOP
	    prefs = Preferences.load_plist(DesktopDefaultPrefs)
	end

	unless prefs
	    $stderr.print \
		"#{$0}: failed to load default preferences\n"
	    return {}
	end

	return prefs
    end

    def initialize
	# Start with the BUILTIN preferences set and merge in the defaults
	# from the static plists. This protects us if they ever go AWOL.
	@current = BUILTIN
	self.merge_prefs(SmbPreferences.defaults)
    end

    # Merge the given set of Preferences with the current set.
    def merge_prefs(prefs)
	keys = @current.keys
	keys.each { | key |
	    # Only override the preference if we know there is a key for it.
	    # This prevents us acceidentally overriding with nil by
	    # referencing a key that's not present, but allows us to override
	    # with nil when it's actually specified.
	    next unless prefs.has_key?(key)

	    val = prefs[key]
	    $stderr.print \
		"updating #{key} from '#{@current[key]}' to '#{val}'\n" \
		if $DEBUG

	    @current[key] = val
	}
    end

    def each
	@current.each do |key, value|
	    yield key, value
	end
    end

    def [](key)
	return @current[key]
    end

    # Write the generated config to the config file.
    def sync_config(optionlist)
	File.open(SmbConfigFile::SmbRunConfigPath, "w") { | fd |
	    config_lines(optionlist) { | line | 
		fd.write(line)
	    }

	    fd.fsync
	}
    end

    # Print the generated config to stdout.
    def print_config(optionlist)
	config_lines(optionlist) { | line | $stdout.print line }
    end

private

    def config_lines(optionlist)
	config = SmbConfigFile.new()
	optionlist.each do |option|
	    begin
		option.emit(@current, config)
	    rescue Exception => err
		if $VERBOSE
		    $stderr.print "#{$0}: failed to handle option #{option}\n"
		    $stderr.print "#{$0}: #{err}\n"
		end
	    end
	end

	yield <<EOF
#
# Configuration options for smbd(8), nmbd(8) and winbindd(8).
#
# This file is automatically generated, DO NOT EDIT!
#
EOF

	config.lines(SmbConfigFile::STATUS) { | key, value |
	    yield "# #{key.capitalize}: #{value}\n"
	}

	yield "#\n\n"

	config.sections.each { |section| 
	    # We already emitted the status section
	    next if section == SmbConfigFile::STATUS

	    if config.has_section? section
		yield "\n[#{section}]\n"
		config.lines(section) { | key, value |
		    yield "    #{key} = #{value}\n"
		}
	    end
	}
    end

end

class Command

    OPTIONS = [
	SimpleOption.new('NetBIOSName', 'netbios name'),
	NullOption.new('NetBIOSNodeType'),
	SimpleOption.new('NetBIOSScope', 'netbios scope'),

	SimpleOption.new('Workgroup', 'workgroup'),

	NullOption.new('AllowKerberosAuth'),
	NullOption.new('AllowNTLM2Auth'),
	SimpleOption.new('AllowNTLMAuth', 'ntlm auth'),
	SimpleOption.new('AllowLanManAuth', 'lanman auth'),

	SimpleOption.new('ServerDescription', 'server string'),
	SimpleOption.new('MaxClients', 'max smbd processes'),
	SimpleOption.new('LoggingLevel', 'log level'),
	SimpleOption.new('DOSCodePage', 'dos charset'),

	KerberosRealmOption.new('KerberosRealm', 'LocalKerberosRealm'),

	GuestAccessOption.new('AllowGuestAccess'),

	MasterBrowserOption.new('MasterBrowser',
			    ServerRoleOption.new('ServerRole', nil)),

	PrefsChangedOption.new('PreferencesSignature',
			    SmbConfigFile::SmbRunConfigPath),

	WinsRegisterOption.new('RegisterWINSName', 'WINSServerAddressList'),

	# Password server can be a hostname, IP address or something more
	# complex. We really only want to support the first two, but since a
	# hostname can be pretty much anything, let's just allow the
	# flexibility.
	SimpleOption.new('PasswordServer', 'password server'),

	ServicesOption.new('EnabledServices'),
	SuspendOption.new('SuspendServices'),
	AutoSharesOption.new('VirtualHomeShares', 'VirtualAdminShares'),

	ServerRoleOption.new('ServerRole',
			    GuestAccessOption.new('AllowGuestAccess'))
    ]

    def Command.SyncPrefs
	begin
	    smbopts = SmbPreferences.new()
	    smbopts.merge_prefs(Preferences.new(SmbPreferences::AppID))
	    smbopts.sync_config(OPTIONS)
	rescue StandardError => err
	    $stderr.print "#{$0}: #{err}\n" if $VERBOSE
	    return 1
	end

	LaunchControl.init
	LaunchControl.sync
	return 0
    end

    def Command.ListPending
	smbopts = SmbPreferences.new()
	smbopts.merge_prefs(Preferences.new(SmbPreferences::AppID))
	smbopts.print_config(OPTIONS)
	return 0
    end

    def Command.ListDefaults
	smbopts = SmbPreferences.new()
	smbopts.print_config(OPTIONS)
	return 0
    end

    def Command.ChangesPending
	if Command.need_pref_sync
	    print "#{$0}: configuration is out of date\n" if $VERBOSE
	    return 0
	else
	    print "#{$0}: configuration is current\n" if $VERBOSE
	    return 2
	end
    end

private

    def Command.need_pref_sync
	prefs = Preferences.new(SmbPreferences::AppID)
	check = PrefsChangedOption.new('PreferencesSignature',
			    SmbConfigFile::SmbRunConfigPath)

	return check.changed(prefs)
    end

end

opts = OptionParser.new
opts.on('--verbose',
'print extra debugging messages') {
    $VERBOSE = true
}

opts.on('--linger=ARG', Integer,
'stick around and sync updates until ARG', 'seconds of inactivity') { |val|
    $LINGER = val.to_i
}

opts.on('--force-sync',
'force synchronization even if it is', 'unnecessary') {
    PrefsChangedOption.force_change
}

opts.on('--restart-services',
'restart any services that are', 'already running') {
    LaunchControl.force_restart
}

# Disable all the services. This is for apps that are going to mess with the
# Samba state in more detail. They should set the preferences, then synchronize
# and suspend. Once they have done the rest of the configuration, they can
# synchronize again and the services will be started.
opts.on('--suspend-services',
"leave all services disabled") {
	LaunchControl.force_disable
}

opts.on('--changes-pending',
'exit with 0 status if there are', 'unsynchronized changes') {
    exit Command.ChangesPending
}

opts.on('--list-pending',
'print the pending configuration, but do', 'not synchronize') {
    exit Command.ListPending
}

opts.on('--list-defaults',
"print the default configuration and exit") {
    exit Command.ListDefaults
}

begin
    opts.parse!(ARGV) # Remove args as they are parsed.
    if (ARGV.length != 0)
	raise OptionParser::InvalidOption, ARGV[0], caller
    end
rescue OptionParser::ParseError => err
    $stderr.print "#{$0}: #{err}\n"
    $stderr.print opts.help()
    exit 1
end

if $LINGER <= 0
    exit Command.SyncPrefs
else

    # Do an initial sync to handle the change that we started up for.
    Command.SyncPrefs

    print "#{$0}: lingering for #{$LINGER}s\n" if $VERBOSE

    stop = Time.now() + $LINGER
    path = \
	"/Library/Preferences/SystemConfiguration/#{SmbPreferences::AppID}.plist"
    last = File.mtime(path)

    # NB. We should be using SCPreferencesSetCallback and a runloop to figure
    # out when the changes happen, but System Prefs writes the plist directly,
    # so we have to resort to checking the mtime.
    while Time.now() < stop
	sleep 0.2
	current = File.mtime(path)
	if last != current
	    last = current
	    Command.SyncPrefs
	    # Push out the stop time since there was some activity. 
	    stop = Time.now() + $LINGER
	end
    end

    exit 0
end

# vim: filetype=ruby ai ts=8 sts=4 sw=4 tw=79