"""Installation utilities for Python ISAPI filters and extensions.""" # this code adapted from "Tomcat JK2 ISAPI redirector", part of Apache # Created July 2004, Mark Hammond. from __future__ import annotations import importlib.machinery import os import shutil import stat import sys import traceback from collections.abc import Mapping import pythoncom import win32api import winerror from win32com.client import GetObject _APP_INPROC = 0 _APP_OUTPROC = 1 _APP_POOLED = 2 _IIS_OBJECT = "IIS://LocalHost/W3SVC" _IIS_SERVER = "IIsWebServer" _IIS_WEBDIR = "IIsWebDirectory" _IIS_WEBVIRTUALDIR = "IIsWebVirtualDir" _IIS_FILTERS = "IIsFilters" _IIS_FILTER = "IIsFilter" _DEFAULT_SERVER_NAME = "Default Web Site" _DEFAULT_HEADERS = "X-Powered-By: Python" _DEFAULT_PROTECTION = _APP_POOLED # Default is for 'execute' only access - ie, only the extension # can be used. This can be overridden via your install script. _DEFAULT_ACCESS_EXECUTE = True _DEFAULT_ACCESS_READ = False _DEFAULT_ACCESS_WRITE = False _DEFAULT_ACCESS_SCRIPT = False _DEFAULT_CONTENT_INDEXED = False _DEFAULT_ENABLE_DIR_BROWSING = False _DEFAULT_ENABLE_DEFAULT_DOC = False this_dir = os.path.abspath(os.path.dirname(__file__)) class FilterParameters: Name = None Description = None Path = None Server = None # Params that control if/how AddExtensionFile is called. AddExtensionFile = True AddExtensionFile_Enabled = True AddExtensionFile_GroupID = None # defaults to Name AddExtensionFile_CanDelete = True AddExtensionFile_Description = None # defaults to Description. def __init__(self, **kw): self.__dict__.update(kw) class VirtualDirParameters: Name = None # Must be provided. Description = None # defaults to Name AppProtection = _DEFAULT_PROTECTION Headers = _DEFAULT_HEADERS Path = None # defaults to WWW root. Type = _IIS_WEBVIRTUALDIR AccessExecute = _DEFAULT_ACCESS_EXECUTE AccessRead = _DEFAULT_ACCESS_READ AccessWrite = _DEFAULT_ACCESS_WRITE AccessScript = _DEFAULT_ACCESS_SCRIPT ContentIndexed = _DEFAULT_CONTENT_INDEXED EnableDirBrowsing = _DEFAULT_ENABLE_DIR_BROWSING EnableDefaultDoc = _DEFAULT_ENABLE_DEFAULT_DOC DefaultDoc = None # Only set in IIS if not None ScriptMaps: list[ScriptMapParams] = [] ScriptMapUpdate = "end" # can be 'start', 'end', 'replace' Server = None def __init__(self, **kw): self.__dict__.update(kw) def is_root(self): "This virtual directory is a root directory if parent and name are blank" parent, name = self.split_path() return not parent and not name def split_path(self): return split_path(self.Name) class ScriptMapParams: Extension = None Module = None Flags = 5 Verbs = "" # Params that control if/how AddExtensionFile is called. AddExtensionFile = True AddExtensionFile_Enabled = True AddExtensionFile_GroupID = None # defaults to Name AddExtensionFile_CanDelete = True AddExtensionFile_Description = None # defaults to Description. def __init__(self, **kw): self.__dict__.update(kw) def __str__(self): "Format this parameter suitable for IIS" items = [self.Extension, self.Module, self.Flags] # IIS gets upset if there is a trailing verb comma, but no verbs if self.Verbs: items.append(self.Verbs) items = [str(item) for item in items] return ",".join(items) class ISAPIParameters: ServerName = _DEFAULT_SERVER_NAME # Description = None Filters: list[FilterParameters] = [] VirtualDirs: list[VirtualDirParameters] = [] def __init__(self, **kw): self.__dict__.update(kw) verbose = 1 # The level - 0 is quiet. def log(level, what): if verbose >= level: print(what) # Convert an ADSI COM exception to the Win32 error code embedded in it. def _GetWin32ErrorCode(com_exc): hr = com_exc.hresult # If we have more details in the 'excepinfo' struct, use it. if com_exc.excepinfo: hr = com_exc.excepinfo[-1] if winerror.HRESULT_FACILITY(hr) != winerror.FACILITY_WIN32: raise return winerror.SCODE_CODE(hr) class InstallationError(Exception): pass class ItemNotFound(InstallationError): pass class ConfigurationError(InstallationError): pass def FindPath(options, server, name): if name.lower().startswith("iis://"): return name else: if name and name[0] != "/": name = "/" + name return FindWebServer(options, server) + "/ROOT" + name def LocateWebServerPath(description): """ Find an IIS web server whose name or comment matches the provided description (case-insensitive). >>> LocateWebServerPath('Default Web Site') # doctest: +SKIP or >>> LocateWebServerPath('1') #doctest: +SKIP """ assert len(description) >= 1, "Server name or comment is required" iis = GetObject(_IIS_OBJECT) description = description.lower().strip() for site in iis: # Name is generally a number, but no need to assume that. site_attributes = [ getattr(site, attr, "").lower().strip() for attr in ("Name", "ServerComment") ] if description in site_attributes: return site.AdsPath msg = "No web sites match the description '%s'" % description raise ItemNotFound(msg) def GetWebServer(description=None): """ Load the web server instance (COM object) for a given instance or description. If None is specified, the default website is retrieved (indicated by the identifier 1. """ description = description or "1" path = LocateWebServerPath(description) server = LoadWebServer(path) return server def LoadWebServer(path): try: server = GetObject(path) except pythoncom.com_error as exc: msg = exc.strerror if exc.excepinfo and exc.excepinfo[2]: msg = exc.excepinfo[2] msg = f"WebServer {path}: {msg}" raise ItemNotFound(msg) return server def FindWebServer(options, server_desc): """ Legacy function to allow options to define a .server property to override the other parameter. Use GetWebServer instead. """ # options takes precedence server_desc = options.server or server_desc # make sure server_desc is unicode (could be mbcs if passed in # sys.argv). if server_desc and not isinstance(server_desc, str): server_desc = server_desc.decode("mbcs") # get the server (if server_desc is None, the default site is acquired) server = GetWebServer(server_desc) return server.adsPath def split_path(path): """ Get the parent path and basename. >>> split_path('/') ['', ''] >>> split_path('') ['', ''] >>> split_path('foo') ['', 'foo'] >>> split_path('/foo') ['', 'foo'] >>> split_path('/foo/bar') ['/foo', 'bar'] >>> split_path('foo/bar') ['/foo', 'bar'] """ if not path.startswith("/"): path = "/" + path return path.rsplit("/", 1) def _CreateDirectory(iis_dir, name, params): # We used to go to lengths to keep an existing virtual directory # in place. However, in some cases the existing directories got # into a bad state, and an update failed to get them working. # So we nuke it first. If this is a problem, we could consider adding # a --keep-existing option. try: # Also seen the Class change to a generic IISObject - so nuke # *any* existing object, regardless of Class assert name.strip("/"), "mustn't delete the root!" iis_dir.Delete("", name) log(2, f"Deleted old directory '{name}'") except pythoncom.com_error: pass newDir = iis_dir.Create(params.Type, name) log(2, f"Creating new directory '{name}' in {iis_dir.Name}...") friendly = params.Description or params.Name newDir.AppFriendlyName = friendly # Note that the new directory won't be visible in the IIS UI # unless the directory exists on the filesystem. try: path = params.Path or iis_dir.Path newDir.Path = path except AttributeError: # If params.Type is IIS_WEBDIRECTORY, an exception is thrown pass newDir.AppCreate2(params.AppProtection) # XXX - note that these Headers only work in IIS6 and earlier. IIS7 # only supports them on the w3svc node - not even on individial sites, # let alone individual extensions in the site! if params.Headers: newDir.HttpCustomHeaders = params.Headers log(2, "Setting directory options...") newDir.AccessExecute = params.AccessExecute newDir.AccessRead = params.AccessRead newDir.AccessWrite = params.AccessWrite newDir.AccessScript = params.AccessScript newDir.ContentIndexed = params.ContentIndexed newDir.EnableDirBrowsing = params.EnableDirBrowsing newDir.EnableDefaultDoc = params.EnableDefaultDoc if params.DefaultDoc is not None: newDir.DefaultDoc = params.DefaultDoc newDir.SetInfo() return newDir def CreateDirectory(params, options): _CallHook(params, "PreInstall", options) if not params.Name: raise ConfigurationError("No Name param") parent, name = params.split_path() target_dir = GetObject(FindPath(options, params.Server, parent)) if not params.is_root(): target_dir = _CreateDirectory(target_dir, name, params) AssignScriptMaps(params.ScriptMaps, target_dir, params.ScriptMapUpdate) _CallHook(params, "PostInstall", options, target_dir) log(1, f"Configured Virtual Directory: {params.Name}") return target_dir def AssignScriptMaps(script_maps, target, update="replace"): """Updates IIS with the supplied script map information. script_maps is a list of ScriptMapParameter objects target is an IIS Virtual Directory to assign the script maps to update is a string indicating how to update the maps, one of ('start', 'end', or 'replace') """ # determine which function to use to assign script maps script_map_func = "_AssignScriptMaps" + update.capitalize() try: script_map_func = eval(script_map_func) except NameError: msg = "Unknown ScriptMapUpdate option '%s'" % update raise ConfigurationError(msg) # use the str method to format the script maps for IIS script_maps = [str(s) for s in script_maps] # call the correct function script_map_func(target, script_maps) target.SetInfo() def get_unique_items(sequence, reference): "Return items in sequence that can't be found in reference." return tuple([item for item in sequence if item not in reference]) def _AssignScriptMapsReplace(target, script_maps): target.ScriptMaps = script_maps def _AssignScriptMapsEnd(target, script_maps): unique_new_maps = get_unique_items(script_maps, target.ScriptMaps) target.ScriptMaps += unique_new_maps def _AssignScriptMapsStart(target, script_maps): unique_new_maps = get_unique_items(script_maps, target.ScriptMaps) target.ScriptMaps = unique_new_maps + target.ScriptMaps def CreateISAPIFilter(filterParams, options): server = FindWebServer(options, filterParams.Server) _CallHook(filterParams, "PreInstall", options) try: filters = GetObject(server + "/Filters") except pythoncom.com_error as exc: # Brand new sites don't have the '/Filters' collection - create it. # Any errors other than 'not found' we shouldn't ignore. if ( winerror.HRESULT_FACILITY(exc.hresult) != winerror.FACILITY_WIN32 or winerror.HRESULT_CODE(exc.hresult) != winerror.ERROR_PATH_NOT_FOUND ): raise server_ob = GetObject(server) filters = server_ob.Create(_IIS_FILTERS, "Filters") filters.FilterLoadOrder = "" filters.SetInfo() # As for VirtualDir, delete an existing one. assert filterParams.Name.strip("/"), "mustn't delete the root!" try: filters.Delete(_IIS_FILTER, filterParams.Name) log(2, f"Deleted old filter '{filterParams.Name}'") except pythoncom.com_error: pass newFilter = filters.Create(_IIS_FILTER, filterParams.Name) log(2, "Created new ISAPI filter...") assert os.path.isfile(filterParams.Path) newFilter.FilterPath = filterParams.Path newFilter.FilterDescription = filterParams.Description newFilter.SetInfo() load_order = [b.strip() for b in filters.FilterLoadOrder.split(",") if b] if filterParams.Name not in load_order: load_order.append(filterParams.Name) filters.FilterLoadOrder = ",".join(load_order) filters.SetInfo() _CallHook(filterParams, "PostInstall", options, newFilter) log(1, f"Configured Filter: {filterParams.Name}") return newFilter def DeleteISAPIFilter(filterParams, options): _CallHook(filterParams, "PreRemove", options) server = FindWebServer(options, filterParams.Server) ob_path = server + "/Filters" try: filters = GetObject(ob_path) except pythoncom.com_error as details: # failure to open the filters just means a totally clean IIS install # (IIS5 at least has no 'Filters' key when freshly installed). log(2, f"ISAPI filter path '{ob_path}' did not exist.") return try: assert filterParams.Name.strip("/"), "mustn't delete the root!" filters.Delete(_IIS_FILTER, filterParams.Name) log(2, f"Deleted ISAPI filter '{filterParams.Name}'") except pythoncom.com_error as details: rc = _GetWin32ErrorCode(details) if rc != winerror.ERROR_PATH_NOT_FOUND: raise log(2, f"ISAPI filter '{filterParams.Name}' did not exist.") # Remove from the load order load_order = [b.strip() for b in filters.FilterLoadOrder.split(",") if b] if filterParams.Name in load_order: load_order.remove(filterParams.Name) filters.FilterLoadOrder = ",".join(load_order) filters.SetInfo() _CallHook(filterParams, "PostRemove", options) log(1, f"Deleted Filter: {filterParams.Name}") def _AddExtensionFile(module, def_groupid, def_desc, params, options): group_id = params.AddExtensionFile_GroupID or def_groupid desc = params.AddExtensionFile_Description or def_desc try: ob = GetObject(_IIS_OBJECT) ob.AddExtensionFile( module, params.AddExtensionFile_Enabled, group_id, params.AddExtensionFile_CanDelete, desc, ) log(2, f"Added extension file '{module}' ({desc})") except (pythoncom.com_error, AttributeError) as details: # IIS5 always fails. Probably should upgrade this to # complain more loudly if IIS6 fails. log(2, f"Failed to add extension file '{module}': {details}") def AddExtensionFiles(params, options): """Register the modules used by the filters/extensions as a trusted 'extension module' - required by the default IIS6 security settings.""" # Add each module only once. added = {} for vd in params.VirtualDirs: for smp in vd.ScriptMaps: if smp.Module not in added and smp.AddExtensionFile: _AddExtensionFile(smp.Module, vd.Name, vd.Description, smp, options) added[smp.Module] = True for fd in params.Filters: if fd.Path not in added and fd.AddExtensionFile: _AddExtensionFile(fd.Path, fd.Name, fd.Description, fd, options) added[fd.Path] = True def _DeleteExtensionFileRecord(module, options): try: ob = GetObject(_IIS_OBJECT) ob.DeleteExtensionFileRecord(module) log(2, "Deleted extension file record for '%s'" % module) except (pythoncom.com_error, AttributeError) as details: log(2, f"Failed to remove extension file '{module}': {details}") def DeleteExtensionFileRecords(params, options): deleted = {} # only remove each .dll once. for vd in params.VirtualDirs: for smp in vd.ScriptMaps: if smp.Module not in deleted and smp.AddExtensionFile: _DeleteExtensionFileRecord(smp.Module, options) deleted[smp.Module] = True for filter_def in params.Filters: if filter_def.Path not in deleted and filter_def.AddExtensionFile: _DeleteExtensionFileRecord(filter_def.Path, options) deleted[filter_def.Path] = True def CheckLoaderModule(dll_name): suffix = "_d" if "_d.pyd" in importlib.machinery.EXTENSION_SUFFIXES else "" template = os.path.join(this_dir, "PyISAPI_loader" + suffix + ".dll") if not os.path.isfile(template): raise ConfigurationError(f"Template loader '{template}' does not exist") # We can't do a simple "is newer" check, as the DLL is specific to the # Python version. So we check the date-time and size are identical, # and skip the copy in that case. src_stat = os.stat(template) try: dest_stat = os.stat(dll_name) except OSError: same = 0 else: same = ( src_stat[stat.ST_SIZE] == dest_stat[stat.ST_SIZE] and src_stat[stat.ST_MTIME] == dest_stat[stat.ST_MTIME] ) if not same: log(2, f"Updating {template}->{dll_name}") shutil.copyfile(template, dll_name) shutil.copystat(template, dll_name) else: log(2, f"{dll_name} is up to date.") def _CallHook(ob, hook_name, options, *extra_args): func = getattr(ob, hook_name, None) if func is not None: args = (ob, options) + extra_args func(*args) def Install(params, options): _CallHook(params, "PreInstall", options) for vd in params.VirtualDirs: CreateDirectory(vd, options) for filter_def in params.Filters: CreateISAPIFilter(filter_def, options) AddExtensionFiles(params, options) _CallHook(params, "PostInstall", options) def RemoveDirectory(params, options): if params.is_root(): return try: directory = GetObject(FindPath(options, params.Server, params.Name)) except pythoncom.com_error as details: rc = _GetWin32ErrorCode(details) if rc != winerror.ERROR_PATH_NOT_FOUND: raise log(2, "VirtualDirectory '%s' did not exist" % params.Name) directory = None if directory is not None: # Be robust should IIS get upset about unloading. try: directory.AppUnLoad() except: exc_val = sys.exc_info()[1] log(2, f"AppUnLoad() for {params.Name} failed: {exc_val}") # Continue trying to delete it. try: parent = GetObject(directory.Parent) parent.Delete(directory.Class, directory.Name) log(1, f"Deleted Virtual Directory: {params.Name}") except: exc_val = sys.exc_info()[1] log(1, f"Failed to remove directory {params.Name}: {exc_val}") def RemoveScriptMaps(vd_params, options): "Remove script maps from the already installed virtual directory" parent, name = vd_params.split_path() target_dir = GetObject(FindPath(options, vd_params.Server, parent)) installed_maps = list(target_dir.ScriptMaps) for _map in map(str, vd_params.ScriptMaps): if _map in installed_maps: installed_maps.remove(_map) target_dir.ScriptMaps = installed_maps target_dir.SetInfo() def Uninstall(params, options): _CallHook(params, "PreRemove", options) DeleteExtensionFileRecords(params, options) for vd in params.VirtualDirs: _CallHook(vd, "PreRemove", options) RemoveDirectory(vd, options) if vd.is_root(): # if this is installed to the root virtual directory, we can't delete it # so remove the script maps. RemoveScriptMaps(vd, options) _CallHook(vd, "PostRemove", options) for filter_def in params.Filters: DeleteISAPIFilter(filter_def, options) _CallHook(params, "PostRemove", options) # Patch up any missing module names in the params, replacing them with # the DLL name that hosts this extension/filter. def _PatchParamsModule(params, dll_name, file_must_exist=True): if file_must_exist: if not os.path.isfile(dll_name): raise ConfigurationError(f"{dll_name} does not exist") # Patch up all references to the DLL. for f in params.Filters: if f.Path is None: f.Path = dll_name for d in params.VirtualDirs: for sm in d.ScriptMaps: if sm.Module is None: sm.Module = dll_name def GetLoaderModuleName(mod_name, check_module=None): # find the name of the DLL hosting us. # By default, this is "_{module_base_name}.dll" if hasattr(sys, "frozen"): # What to do? The .dll knows its name, but this is likely to be # executed via a .exe, which does not know. base, ext = os.path.splitext(mod_name) path, base = os.path.split(base) # handle the common case of 'foo.exe'/'foow.exe' if base.endswith("w"): base = base[:-1] # For py2exe, we have '_foo.dll' as the standard pyisapi loader - but # 'foo.dll' is what we use (it just delegates). # So no leading '_' on the installed name. dll_name = os.path.abspath(os.path.join(path, base + ".dll")) else: base, ext = os.path.splitext(mod_name) path, base = os.path.split(base) dll_name = os.path.abspath(os.path.join(path, "_" + base + ".dll")) # Check we actually have it. if check_module is None: check_module = not hasattr(sys, "frozen") if check_module: CheckLoaderModule(dll_name) return dll_name # Note the 'log' params to these 'builtin' args - old versions of pywin32 # didn't log at all in this function (by intent; anyone calling this was # responsible). So existing code that calls this function with the old # signature (ie, without a 'log' param) still gets the same behaviour as # before... def InstallModule(conf_module_name, params, options, log=lambda *args: None): "Install the extension" if not hasattr(sys, "frozen"): conf_module_name = os.path.abspath(conf_module_name) if not os.path.isfile(conf_module_name): raise ConfigurationError(f"{conf_module_name} does not exist") loader_dll = GetLoaderModuleName(conf_module_name) _PatchParamsModule(params, loader_dll) Install(params, options) log(1, "Installation complete.") def UninstallModule(conf_module_name, params, options, log=lambda *args: None): "Remove the extension" loader_dll = GetLoaderModuleName(conf_module_name, False) _PatchParamsModule(params, loader_dll, False) Uninstall(params, options) log(1, "Uninstallation complete.") standard_arguments = { "install": InstallModule, "remove": UninstallModule, } def build_usage(handler_map: Mapping[str, object]) -> str: arg_names = "|".join(handler_map) lines = [ " %-10s: %s" % (arg, handler.__doc__) for arg, handler in handler_map.items() ] return f"%prog [options] [{arg_names}]\ncommands:\n" + "\n".join(lines) def MergeStandardOptions(options, params): """ Take an options object generated by the command line and merge the values into the IISParameters object. """ pass # We support 2 ways of extending our command-line/install support. # * Many of the installation items allow you to specify "PreInstall", # "PostInstall", "PreRemove" and "PostRemove" hooks # All hooks are called with the 'params' object being operated on, and # the 'optparser' options for this session (ie, the command-line options) # PostInstall for VirtualDirectories and Filters both have an additional # param - the ADSI object just created. # * You can pass your own option parser for us to use, and/or define a map # with your own custom arg handlers. It is a map of 'arg'->function. # The function is called with (options, log_fn, arg). The function's # docstring is used in the usage output. def HandleCommandLine( params, argv=None, conf_module_name=None, default_arg="install", opt_parser=None, custom_arg_handlers={}, ): """Perform installation or removal of an ISAPI filter or extension. This module handles standard command-line options and configuration information, and installs, removes or updates the configuration of an ISAPI filter or extension. You must pass your configuration information in params - all other arguments are optional, and allow you to configure the installation process. """ global verbose from optparse import OptionParser argv = argv or sys.argv if not conf_module_name: conf_module_name = sys.argv[0] # convert to a long name so that if we were somehow registered with # the "short" version but unregistered with the "long" version we # still work (that will depend on exactly how the installer was # started) try: conf_module_name = win32api.GetLongPathName(conf_module_name) except win32api.error as exc: log( 2, f"Couldn't determine the long name for {conf_module_name!r}: {exc}", ) if opt_parser is None: # Build our own parser. parser = OptionParser(usage="") else: # The caller is providing their own filter, presumably with their # own options all setup. parser = opt_parser # build a usage string if we don't have one. if not parser.get_usage(): all_handlers = standard_arguments.copy() all_handlers.update(custom_arg_handlers) parser.set_usage(build_usage(all_handlers)) # allow the user to use uninstall as a synonym for remove if it wasn't # defined by the custom arg handlers. all_handlers.setdefault("uninstall", all_handlers["remove"]) parser.add_option( "-q", "--quiet", action="store_false", dest="verbose", default=True, help="don't print status messages to stdout", ) parser.add_option( "-v", "--verbosity", action="count", dest="verbose", default=1, help="increase the verbosity of status messages", ) parser.add_option( "", "--server", action="store", help="Specifies the IIS server to install/uninstall on." f" Default is '{_IIS_OBJECT}/1'", ) (options, args) = parser.parse_args(argv[1:]) MergeStandardOptions(options, params) verbose = options.verbose if not args: args = [default_arg] try: for arg in args: handler = all_handlers[arg] handler(conf_module_name, params, options, log) except (ItemNotFound, InstallationError) as details: if options.verbose > 1: traceback.print_exc() print(f"{details.__class__.__name__}: {details}") except KeyError: parser.error("Invalid arg '%s'" % arg)