#!/usr/local/munki/python # encoding: utf-8 # # Copyright 2017-2020 Greg Neagle. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # https://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. """ authrestartd Created by Greg Neagle on 2017-04-15. Much code based on and adapted from autopkgserver by Per Olofsson A helper tool for FileVault authorized restarts. Allows non-privileged users to get certain info about FileVault, and to store a password for authrestart. Root user can trigger an authrestart, possibly using the password stored earlier by a non-privileged user. """ from __future__ import absolute_import, print_function import os import sys import time import stat import logging import logging.handlers try: import SocketServer except ImportError: import socketserver as SocketServer import socket import struct from munkilib import authrestart from munkilib import launchd from munkilib import prefs from munkilib.wrappers import readPlistFromString, PlistError, unicode_or_str APPNAME = 'authrestartd' VERSION = '0.2' class FDEUtilError(Exception): '''Exception to raise if there is any error in FDEUtil''' pass class FDEUtil(object): '''Class for working with fdesetup''' def __init__(self, server, request, uid): '''Arguments: request A request in plist format. uid The uid of the requestor ''' self.server = server self.log = server.log self.request = request self.uid = uid def verify_request(self): '''Make sure copy request has everything we need''' self.log.debug('Verifying request') for key in ['task']: if not key in self.request: raise FDEUtilError('No %s in request' % key) def store_password(self, password, username=None): '''Stores a password for later use for authrestart''' self.server.stored_password = password if username: self.server.stored_username = username def handle(self): '''Handle our request''' self.verify_request() if self.request['task'] == 'restart': # Attempt to perform an authrestart, falling back to a regular # restart self.log.info('Restart request from uid %s', self.uid) if self.uid == 0: self.log.info('Stored username for authrestart: %s', self.server.stored_username) authrestart.do_authorized_or_normal_restart( password=self.server.stored_password, username=self.server.stored_username) return 'RESTARTING' else: self.log.info('Restart request denied') raise FDEUtilError('Restart may only be triggered by root') if self.request['task'] == 'delayed_authrestart': # set up a delayed authrestart. Defaults to waiting indefinitely, # so the next "normal" restart becomes an authrestart. self.log.info('Delayed restart request from uid %s', self.uid) if self.uid == 0: self.log.info('Stored username for authrestart: %s', self.server.stored_username) delayminutes = self.request.get('delayminutes', -1) authrestart.perform_auth_restart( password=self.server.stored_password, username=self.server.stored_username, delayminutes=delayminutes) return 'DONE' else: self.log.info('Delayed restart request denied') raise FDEUtilError( 'Delayed restart may only be triggered by root') if self.request['task'] == 'store_password': # store a password for later fdesetup authrestart self.log.info('Store password request') password = self.request.get('password') if not password: self.log.info('No password in request') raise FDEUtilError('No password provided') username = self.request.get('username') self.log.info('Request username: %s', username) # don't store the password if the user isn't enabled for FileVault if (username and not authrestart.can_attempt_auth_restart_for(username)): self.log.info('User %s can\'t do auth restart', username) raise FDEUtilError( 'User %s can\'t do FileVault authrestart' % username) self.store_password(password, username=username) self.log.info('Password stored.') return 'DONE' if self.request['task'] == 'verify_can_attempt_auth_restart': # Check if we have all the required bits to attempt an auth # restart. self.log.info('Verify ready for auth restart') if authrestart.can_attempt_auth_restart( have_password=bool(self.server.stored_password)): self.log.info('Ready for auth restart attempt') return 'READY' else: self.log.info('Not ready for auth restart attempt') raise FDEUtilError('Not ready for auth restart attempt') if self.request['task'] == 'verify_recovery_key_present': # Check if a plist containing a recovery key or password is # present. self.log.info('Verify recovery key request') if authrestart.get_auth_restart_key(quiet=True) == '': self.log.info('No valid recovery key plist') raise FDEUtilError('No recovery key plist') self.log.info('Valid recovery key plist found') return 'PRESENT' if self.request['task'] == 'verify_user': # Check to see if we can perform an authrestart for this user. # FileVault must be active, the hardware must support authrestart, # and the user must be enabled for FileVault. username = self.request.get('username') if not username: self.log.info('Verify user request with no username') raise FDEUtilError('No username provided') self.log.info('Verify user request for %s', username) if not authrestart.can_attempt_auth_restart_for(username): self.log.info( 'User %s can\'t do FileVault authrestart' % username) raise FDEUtilError('User not authorized for FileVault') self.log.info('User %s ok for auth restart', username) return 'USER VERIFIED' if self.request['task'] == 'verify_filevault': # check if FileVault is active self.log.info('Verify FileVault request') if not authrestart.filevault_is_active(): self.log.info('FileVault is not active.') raise FDEUtilError('FileVault is not active') self.log.info('FileVault is active.') return 'FILEVAULT ON' # the task is not one we know how to handle self.log.info('Unknown task request: %s', self.request['task']) raise FDEUtilError('Unknown task') class RunHandler(SocketServer.StreamRequestHandler): '''Handler for restarthelper run requests''' def verify_request_syntax(self, plist): '''Verify the basic syntax of request plist.''' # pylint: disable=no-self-use # Keep a list of error messages. errors = list() # Root should be a dictionary. if not isinstance(plist, dict): errors.append('Request root is not a dictionary') # Bail out early if it's not. return False, errors syntax_ok = True # TO-DO: Actual verification! return syntax_ok, errors def getpeerid(self): ''' Get peer credentials on a UNIX domain socket. Returns uid, gids. ''' # /usr/include/sys/ucred.h # # struct xucred { # u_int cr_version; /* structure layout version */ # uid_t cr_uid; /* effective user id */ # short cr_ngroups; /* number of advisory groups */ # gid_t cr_groups[NGROUPS]; /* advisory group list */ # }; # pylint: disable=invalid-name LOCAL_PEERCRED = 0x001 XUCRED_VERSION = 0 NGROUPS = 16 # pylint: enable=invalid-name cr_version = 0 cr_uid = 1 cr_ngroups = 2 cr_groups = 3 xucred_fmt = b'IIh%dI' % NGROUPS res = struct.unpack( xucred_fmt, self.request.getsockopt( 0, LOCAL_PEERCRED, struct.calcsize(xucred_fmt))) if res[cr_version] != XUCRED_VERSION: raise OSError('Incompatible struct xucred version') return res[cr_uid], res[cr_groups:cr_groups + res[cr_ngroups]] def handle(self): '''Handle an incoming run request.''' try: # Log through server parent. self.log = self.server.log self.log.debug('Handling request') # Get uid and primary gid of connecting peer. uid, gids = self.getpeerid() gid = gids[0] self.log.debug( 'Got request from uid %d gid %d' % (uid, gid)) # Receive a plist. plist_string = self.request.recv(8192) # Try to parse it. try: plist = readPlistFromString(plist_string) except PlistError as err: self.log.error('Malformed request') self.request.send(u'ERROR:Malformed request\n'.encode('UTF-8')) return self.log.debug('Parsed request plist') # Verify the plist syntax. syntax_ok, errors = self.verify_request_syntax(plist) if not syntax_ok: self.log.error('Plist syntax error') msg = (''.join( ['ERROR:%s\n' % e for e in errors])).encode('UTF-8') self.request.send(msg) return self.log.info( 'Dispatching worker to process request for user %d' % (uid)) try: fdeutil = FDEUtil(self.server, plist, uid) result = fdeutil.handle() self.request.send( (u'OK:%s\n' % unicode_or_str(result)).encode('UTF-8')) except FDEUtilError as err: self.request.send( (u'ERROR:%s\n' % unicode_or_str(err)).encode('UTF-8')) except Exception as err: raise #self.log.error(u'Run failed: %s' % unicode_or_str(err)) #self.request.send( # (u'ERROR:%s\n' % unicode_or_str(err)).encode('UTF-8')) except Exception as err: self.log.error('Caught exception: %s' % repr(err)) self.request.send( (u'ERROR:Caught exception: %s' % repr(err)).encode('UTF-8')) raise class RestartHelperDaemonError(Exception): '''Exception to raise for RestartHelperDaemon errors''' pass class RestartHelperDaemon(SocketServer.UnixStreamServer): '''Daemon that runs as root, receiving requests for fdesetup tasks including authrestart.''' allow_reuse_address = True request_queue_size = 10 timeout = 10 def __init__(self, socket_fd, RequestHandlerClass): # Avoid initialization of UnixStreamServer as we need to open the # socket from a file descriptor instead of creating our own. # pylint: disable=super-init-not-called self.socket = socket.fromfd( socket_fd, socket.AF_UNIX, socket.SOCK_STREAM) self.socket.listen(self.request_queue_size) # now do the base class's init # pylint: disable=non-parent-init-called SocketServer.BaseServer.__init__( self, self.socket.getsockname(), RequestHandlerClass) self.timed_out = False self.stored_password = None self.stored_username = None self.log = logging.getLogger(APPNAME) def setup_logging(self): '''Configure logging''' try: self.log.setLevel(logging.DEBUG) log_console = logging.StreamHandler() log_console.setLevel(logging.DEBUG) # store the log in the same directory as ManagedSoftwareUpdate.log logfilepath = os.path.join( os.path.dirname(prefs.pref('LogFile')), APPNAME + '.log') log_file = logging.handlers.RotatingFileHandler( logfilepath, 'a', 100000, 9, 'utf-8') log_file.setLevel(logging.DEBUG) console_formatter = logging.Formatter('%(message)s') file_formatter = logging.Formatter( '%(asctime)s %(module)s[%(process)d]: ' '%(message)s (%(funcName)s)') log_console.setFormatter(console_formatter) log_file.setFormatter(file_formatter) self.log.addHandler(log_console) self.log.addHandler(log_file) except (OSError, IOError) as err: raise RestartHelperDaemonError( 'Can\'t open log: %s' % (err.strerror)) def handle_timeout(self): self.timed_out = True def main(): '''Start our daemon, connect to socket and process requests''' # Make sure we're launched as root if os.geteuid() != 0: print('%s must be run as root.' % APPNAME, file=sys.stderr) # Sleep to avoid respawn. time.sleep(10) return 1 # Make sure that the executable and all containing directories are owned # by root:wheel or root:admin, and not writeable by other users. root_uid = 0 wheel_gid = 0 admin_gid = 80 exepath = os.path.realpath(os.path.abspath(__file__)) path_ok = True while True: info = os.stat(exepath) if info.st_uid != root_uid: print('%s must be owned by root.' % exepath, file=sys.stderr) path_ok = False if info.st_gid not in (wheel_gid, admin_gid): print('%s must have group wheel or admin.' % exepath, file=sys.stderr) path_ok = False if info.st_mode & stat.S_IWOTH: print('%s mustn\'t be world writeable.' % exepath, file=sys.stderr) path_ok = False exepath = os.path.dirname(exepath) if exepath == '/': break if not path_ok: # Sleep to avoid immediate respawn. time.sleep(10) return 1 # Keep track of time for launchd. start_time = time.time() # Get socket file descriptors from launchd. socket_fd = launchd.get_socket_fd('authrestartd'.encode("UTF-8")) if not socket_fd: print('No socket provided to us by launchd', file=sys.stderr) time.sleep(10) return 1 # Create the daemon object. daemon = RestartHelperDaemon(socket_fd, RunHandler) daemon.setup_logging() daemon.log.info('%s v%s starting', APPNAME, VERSION) # Process incoming requests while True: daemon.handle_request() if not daemon.timed_out or daemon.stored_password: continue # we timed out and we don't have a stored password, so we can exit # Keep running for at least 10 seconds make launchd happy. run_time = time.time() - start_time daemon.log.info("run time: %fs", run_time) if run_time < 10.0: # Only sleep for a short while in case new requests pop up. sleep_time = min(1.0, 10.0 - run_time) daemon.log.debug( "sleeping for %f seconds to make launchd happy", sleep_time) time.sleep(sleep_time) else: daemon.log.info('Nothing to do: exiting') break if __name__ == '__main__': sys.exit(main())