GUI Scripting AirPort Menu Bar Selection Via Policy

lg-jbarclay
New Contributor II

Hi there,

Has anyone had any success running GUI scripts through the Casper Suite as part of a recurring policy? I'm writing a script to automate the migration from a WPA2/PSK network to a WPA Enterprise network in an environment where the endpoints are not domain joined. The problem I'm running into is the part of the script that's responsible for clicking the AirPort menu bar icon and selecting the preferred SSID is not executing when run as part of a policy. I can make the exception by modifying the TCC.db SQLite database from the script if I run the policy with a manual trigger from ARD, (ARDAgent is given access to control the computer). It also works if I run the script manually from the command line.

I've tried making exceptions to the TCC.db database for com.jamfsoftware.jamf.agent, com.jamfsoftware.task.Every 15 Minutes, com.jamfsoftware.jamf.daemon, and com.jamfsoftware.startupItem. I've also tried wrapping the script in a payload-free package and making the exception for com.apple.installer and /System/Library/PrivateFrameworks/PackageKit.Framework/Resources/installd.

I should also mention that the '-setairportnetwork' option in networksetup has not worked for programmatically connecting to an 802.1X network in my testing.

I'm attaching the script in case anyone has any insight.

Thanks for reading!

#!/usr/bin/python

'''
This script will configure an OS X
client to connect to the specified
802.1X WLAN.

Created by James Barclay on 2014-02-18

'''

import CoreFoundation
import logging
import os
import Quartz
import re
import sqlite3
import subprocess
import sys
import time

from Foundation import *

# Constants
CONFIG_PROFILE_ID       = 'com.company.wireless'
COMPANY_DIR             = '/Library/Application Support/.company'
PREFERRED_SSID          = 'Wireless'
TCC_DIR                 = '/Library/Application Support/com.apple.TCC'
DBPATH                  = os.path.join(TCC_DIR, 'TCC.db')
WIRELESS_CONFIG_PROFILE = '/private/tmp/wireless.mobileconfig'

AIRPORT                 = '/System/Library/PrivateFrameworks/Apple80211.framework/Versions/Current/Resources/airport'
NETWORK_SETUP           = '/usr/sbin/networksetup'
PROFILES                = '/usr/bin/profiles'

def get_console_user():
    '''Returns the currently logged-in user as
    a string, even if running as EUID root.'''
    if os.geteuid() == 0:
        console_user = subprocess.check_output(['/usr/bin/stat',
                                                '-f%Su',
                                                '/dev/console']).strip()
    else:
        import getpass
        console_user = getpass.getuser()

    return console_user

def logger(msg):
    log_dir = os.path.join(COMPANY_DIR, 'Logs')
    if not os.path.exists(log_dir):
        os.makedirs(log_dir)

    logging.basicConfig(filename=os.path.join(log_dir, 'wireless_connect.log'),
                        format='%(asctime)s %(message)s',
                        level=logging.DEBUG)
    logging.getLogger().addHandler(logging.StreamHandler(stream=sys.stdout))
    logging.debug(msg)

def create_tcc_db(path):
    '''Creates the TCC.db SQLite database at 
    the specified path.'''
    conn = sqlite3.connect(path)
    c = conn.cursor()

    c.execute('''CREATE TABLE admin
                 (key TEXT PRIMARY KEY NOT NULL, value INTEGER NOT NULL)''')
    c.execute("INSERT INTO admin VALUES ('version', 4)")
    c.execute('''CREATE TABLE access
                 (service TEXT NOT NULL,
                    client TEXT NOT NULL,
                    client_type INTEGER NOT NULL,
                    allowed INTEGER NOT NULL,
                    prompt_count INTEGER NOT NULL,
                    CONSTRAINT key PRIMARY KEY (service, client, client_type))''')
    c.execute('''CREATE TABLE access_times
                 (service TEXT NOT NULL,
                    client TEXT NOT NULL,
                    client_type INTEGER NOT NULL,
                    last_used_time INTEGER NOT NULL,
                    CONSTRAINT key PRIMARY KEY (service, client, client_type))''')
    c.execute('''CREATE TABLE access_overrides
                 (service TEXT PRIMARY KEY NOT NULL)''')
    conn.commit()
    conn.close()

def accessibility_allow(bundle_id, allow, client_type=0):
    '''Modifies the access table in the TCC SQLite database.
    Takes the bundle ID and a Boolean as arguments.

    e.g., accessibility_allow('com.apple.Mail', True)'''
    tcc_db_exists = False
    if not os.path.exists(TCC_DIR):
        os.mkdir(TCC_DIR, int('700', 8))
    else:
        tcc_db_exists = True

    conn = sqlite3.connect(DBPATH)
    c = conn.cursor()

    # Setup the database if it doesn't already exist
    if not tcc_db_exists:
        create_tcc_db(DBPATH)

    if isinstance(allow, bool):
        if allow:
            c.execute('''INSERT or REPLACE INTO access values
                ('kTCCServiceAccessibility', ?, ?, 1, 0, NULL)''', (bundle_id, client_type,))
            conn.commit()
        else:
            c.execute('''INSERT or REPLACE INTO access values
                ('kTCCServiceAccessibility', ?, ?, 0, 0, NULL)''', (bundle_id, client_type,))
            conn.commit()
    else:
        logger('%s is not of type bool.' % allow)

    conn.close()

def screen_is_locked():
    '''Returns True if the screen is locked.
    Had to fork this with `su -c` because 
    Quartz.CGSessionCopyCurrentDictionary will
    will return None if no UI session exists
    for the user that spawned the process,
    (root in this case).'''
    console_user = get_console_user()
    cmd = '/usr/bin/python -c 'import sys, Quartz; d = Quartz.CGSessionCopyCurrentDictionary(); print d''
    d = subprocess.check_output(['/usr/bin/sudo',
                                 '/usr/bin/su',
                                 '%s' % console_user,
                                 '-c',
                                 cmd])

    locked = 'kCGSSessionOnConsoleKey = 0'
    sleeping = 'CGSSessionScreenIsLocked = 1'

    logger('Quartz.CGSessionCopyCurrentDictionary returned the following for %s:
%s' % (console_user, d))

    if locked in d or sleeping in d:
        return True

def at_login_window():
    '''Returns True if running at the login window.'''
    ps_output = subprocess.check_output(['/bin/ps', 'aux'])
    if 'Finder.app' not in ps_output:
        return True

def get_pref_val(key, domain):
    '''Returns the preference value for the specified key
    and preference domain.'''
    console_user = get_console_user()
    val = None
    try:
        cmd = '/usr/bin/python -c 'import CoreFoundation; val = CoreFoundation.CFPreferencesCopyAppValue("%s", "%s"); print val'' % (key, domain)
        val = subprocess.check_output(['/usr/bin/su',
                                       '%s' % console_user,
                                       '-c',
                                       cmd])
    except subprocess.CalledProcessError, e:
        logger('An error occurred when attempting to retrieve a value for key %s in %s. Error: %s' % (key, val, e))

    return val

def ask_for_password(boolean):
    askForPassword = {
        'com.apple.screensaver': {
            'askForPassword': boolean,
        },
    }

    CoreFoundation.CFPreferencesSetAppValue('askForPassword', askForPassword, 'com.apple.screensaver')

def get_wireless_interface():
    '''Returns the wireless interface device
    name, (e.g., en0 or en1).'''
    hardware_ports = subprocess.check_output([NETWORK_SETUP,
                                              '-listallhardwareports'])

    match = re.search("(AirPort|Wi-Fi).*?(en\d)", hardware_ports, re.S)

    if match:
        wireless_interface = match.group(2)

    return wireless_interface

def get_current_wlan(interface):
    '''Returns the currently connected WLAN name.'''
    wireless_status = subprocess.check_output([AIRPORT,
                                               '-I',
                                               interface]).split('
')

    current_wlan_name = None
    for line in wireless_status:
        match = re.search(r' +SSID:s(.*)', line)
        if match:
            current_wlan_name = match.group(1)

    return current_wlan_name

def remove_old_ssids(interface):
    '''Removes previously used SSIDs from
    the preferred networks list.'''
    old_ssids = ['Test1', 'Test2', 'Test3', 'Guest']
    for ssid in old_ssids:
        subprocess.check_output([NETWORK_SETUP,
                                 '-removepreferredwirelessnetwork',
                                 interface,
                                 ssid])

def wireless_setup_done():
    '''Returns True if setup has been done before.'''
    path = os.path.join(COMPANY_DIR, 'wireless_setup_done')
    if os.path.exists(path):
        return True

def is_preferred_wlan_available():
    '''Returns True if the preferred WLAN
    is currently available.'''
    available_ssids = subprocess.check_output([AIRPORT, '-s'])
    if PREFERRED_SSID in available_ssids:
        return True

def touch(path):
    '''Mimics the behavior of the `touch`
    command-line utility.'''
    with open(path, 'a'):
        os.utime(path, None)

def enable_assistive_devices():
    '''Enables access to Assistive Devices if
    not currently enabled. Requires root privs.'''
    accessibility_api_enabled = '/private/var/db/.AccessibilityAPIEnabled'
    if not os.path.isfile(accessibility_api_enabled):
        touch(accessibility_api_enabled)

def is_config_profile_installed():
    '''Returns True if CONFIG_PROFILE is installed.'''
    installed_profiles = subprocess.check_output([PROFILES, '-C'])
    if CONFIG_PROFILE_ID in installed_profiles:
        return True

def install_8021X_config_profile(path):
    '''Installs configuration profile at path.'''
    try:
        subprocess.check_output([PROFILES,
                                 '-I',
                                 '-F',
                                 path])
    except subprocess.CalledProcessError, e:
        logger('Unable to install configuration profile %s. Error: %s.' % (WIRELESS_CONFIG_PROFILE, e))

def connect_to_wlan(ssid):
    '''Connects to specified SSID via Python 
    <-> AppleScript <-> Objective-C bridge. It's
    a terrible GUI scripting hack.'''
    cmd = '''set the_ssid to "%s"
    tell application "System Events" to tell process "SystemUIServer"
        tell menu bar 1
            set menu_extras to value of attribute "AXDescription" of menu bar items
            repeat with the_menu from 1 to the count of menu_extras
                set menu_extra_strings to item the_menu of menu_extras
                if item the_menu of menu_extras contains "Wi-Fi" then tell menu bar item the_menu
                    click
                    delay 4
                    click menu item the_ssid of front menu
                    delay 0.5
                end tell
            end repeat
        end tell -- menu bar 1
    end tell
    ''' % ssid

    s = NSAppleScript.alloc().initWithSource_(cmd)
    s.executeAndReturnError_(None)

def prompt_user_to_change_wlan(wireless_interface, current_wlan, old_ss_policy):
    '''Displays a dialog with jamfHelper about
    the wireless network changing.'''

    description = '''You are switching from the %s wireless network to %s. Are you sure you want to continue?
There will be a brief loss of connectivity, so be sure to save your work. After clicking Continue you
will be prompted for your LDAP credentials.''' % (current_wlan, PREFERRED_SSID)

    jamf_helper = '/Library/Application Support/JAMF/bin/jamfHelper.app/Contents/MacOS/jamfHelper'
    heading     = 'Connect to %s?' % PREFERRED_SSID
    icon        = '/Library/User Pictures/Company/Company.tiff'
    title       = 'Company Wireless Configuration'

    response = None
    try:
        response = subprocess.check_call(['/Library/Application Support/JAMF/bin/jamfHelper.app/Contents/MacOS/jamfHelper',
                                          '-windowType',
                                          'utility',
                                          '-icon',
                                          '%s' % icon,
                                          '-title',
                                          '%s' % title,
                                          '-heading',
                                          '%s' % heading,
                                          '-description',
                                          '%s' % description,
                                          '-button1',
                                          '"OK"',
                                          '-button2',
                                          '"Cancel"',
                                          '-cancelButton',
                                          '"2"'])
    except subprocess.CalledProcessError, e:
        logger('An error occurred when attempting to run jamfHelper.
Return code: %s
Error: %s' % (e.returncode, e))

    console_user = get_console_user()

    if str(response).isdigit():
        response = int(response)

        if response == 0:
            logger('%s clicked OK.' % console_user)
            # Enable Assistive Devices if needed
            enable_assistive_devices()
            # Install the 802.1X Configuration Profile
            install_8021X_config_profile(WIRELESS_CONFIG_PROFILE)
            # Sleep for 5 seconds
            time.sleep(5)
            # Allow controlling of the computer
            accessibility_allow('/System/Library/PrivateFrameworks/PackageKit.Framework/Resources/installd', True, client_type=1)
            accessibility_allow('org.python.python', True)
            accessibility_allow('com.apple.installer', True)
            accessibility_allow('com.jamfsoftware.task.Every 15 Minutes', True)
            accessibility_allow('com.jamfsoftware.jamf.agent', True)
            accessibility_allow('com.jamfsoftware.jamf.daemon', True)
            accessibility_allow('com.jamfsoftware.startupItem', True)
            # Connect to the WLAN
            connect_to_wlan(PREFERRED_SSID)
            # Sleep for two minutes
            time.sleep(120)
            # Disallow controlling of the computer
            accessibility_allow('/System/Library/PrivateFrameworks/PackageKit.Framework/Resources/installd', False, client_type=1)
            accessibility_allow('org.python.python', False)
            accessibility_allow('com.apple.installer', False)
            accessibility_allow('com.jamfsoftware.task.Every 15 Minutes', False)
            accessibility_allow('com.jamfsoftware.jamf.agent', False)
            accessibility_allow('com.jamfsoftware.jamf.daemon', False)
            accessibility_allow('com.jamfsoftware.startupItem', False)
            # Reset screensaver password prefs
            new_ss_policy = get_pref_val('askForPassword', 'com.apple.screensaver')
            if old_ss_policy != new_ss_policy:
                ask_for_password(old_ss_policy)

            if get_current_wlan(wireless_interface) == PREFERRED_SSID:
                # Remove old SSIDs from preferred networks
                remove_old_ssids(wireless_interface)
                # Create dummy receipt so we know this has run successfully
                wireless_setup_done = os.path.join(COMPANY_DIR, 'wireless_setup_done')
                touch(wireless_setup_done)

        elif response == 2:
            logger('%s cancelled the operation. Exiting now.' % console_user)
            sys.exit(1)

        else:
            logger('Unknown response: %s. Exiting now.' % response)
            sys.exit(1)
    else:
        logger('Response %s is not a digit. Exiting now.' % response)
        sys.exit(1)

def main():
    if wireless_setup_done():
        logger('The wireless configuration has already been completed. Exiting now.')
        sys.exit(1)

    if at_login_window():
        logger('We appear to be running from the login window. Exiting now.')
        sys.exit(1)

    if screen_is_locked():
        logger('We appear to be running from the lock screen. Exiting now.')
        sys.exit(1)

    if is_preferred_wlan_available() is not True:
        logger('%s is not available. Exiting now.' % PREFERRED_SSID)
        sys.exit(1)

    if is_config_profile_installed() is not True:
        # Get current screensaver policy
        old_ss_policy = get_pref_val('askForPassword', 'com.apple.screensaver')
        # Disable screensaver password temporarily (if needed)
        if old_ss_policy:
            ask_for_password(False)
        # Get the wireless interface, (e.g., en0)
        wireless_interface = get_wireless_interface()
        # Get the current wireless network name
        current_wlan = get_current_wlan(wireless_interface)
        # Prompt the user to continue
        prompt_user_to_change_wlan(wireless_interface, current_wlan, old_ss_policy)
    else:
        logger('The configuration profile with ID '%s' is already installed. Exiting now.' % CONFIG_PROFILE_ID)
        sys.exit(1)

if __name__ == '__main__':
    main()
1 ACCEPTED SOLUTION

lg-jbarclay
New Contributor II

UPDATE: This was resolved by using the pymaKeyboard python module to control the keyboard inputs directly.

https://github.com/pudquick/pymaKeyboard

It now works like this:

  1. Removes the AirPort menu extra temporarily then adds it back, (so the position stays the same).
  2. Sends control-f8, down arrow, the string of the SSID, and return, which prompts the user to authenticate with the native OS dialog.

View solution in original post

1 REPLY 1

lg-jbarclay
New Contributor II

UPDATE: This was resolved by using the pymaKeyboard python module to control the keyboard inputs directly.

https://github.com/pudquick/pymaKeyboard

It now works like this:

  1. Removes the AirPort menu extra temporarily then adds it back, (so the position stays the same).
  2. Sends control-f8, down arrow, the string of the SSID, and return, which prompts the user to authenticate with the native OS dialog.