借助计划任务执行需要管理员权限运行的程序

在多次挣扎之后,我们的新项目最终决定放弃在watchdog上缝缝补补,在Windows平台上改回成用USN日志的方式监控文件变动。随之带来一个问题:拥有管理员权限的进程才能读取USN日志。
旧项目的方案是额外注册一个系统服务,读取usn日志,并通过管道与主进程通讯。但上线之后发现,系统服务很容易被各种电脑管家误删除,导致程序工作异常。新项目打算在主进程中直接读取usn日志。这就要求使用管理员权限运行主进程。而我们又不想让用户每次启动程序时都弹出一个UAC提示框。最后决定借助计划任务来实现我们的需求。

原理

运行等级(Principal.RunLevel)为TASK_RUNLEVEL_HIGHEST的计划任务,在执行时,被调用的进程会以管理员权限运行。
这种计划任务仅在添加时需要管理员权限。在被触发时,不需要管理员权限,也就不会弹出UAC提示。

实现

创建计划任务

通过com组件ITaskService添加计划任务
重点是把Principal.RunLevel设为TASK_RUNLEVEL_HIGHEST
下列代码展示了如何用Python创建最高权限的计划任务。
注意:运行下列代码时需要管理员权限

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
import sys
import os

import win32com.client

NORMAL_PRIORITY_CLASS = 4
TASK_ACTION_EXEC = 0
TASK_CREATE_OR_UPDATE = 6
TASK_INSTANCES_PARALLEL = 0
TASK_LOGON_INTERACTIVE_TOKEN = 3
TASK_RUNLEVEL_HIGHEST = 1

schedule_service = win32com.client.Dispatch('Schedule.Service')
schedule_service.Connect()

task_definition = schedule_service.NewTask(0)
task_definition.Principal.LogonType = TASK_LOGON_INTERACTIVE_TOKEN
task_definition.Principal.RunLevel = TASK_RUNLEVEL_HIGHEST
task_definition.RegistrationInfo.Author = 'Daniel'
task_definition.Settings.DisallowStartIfOnBatteries = False
task_definition.Settings.DisallowStartOnRemoteAppSession = False
task_definition.Settings.Enabled = True
task_definition.Settings.ExecutionTimeLimit = 'PT0S'
task_definition.Settings.MultipleInstances = TASK_INSTANCES_PARALLEL
task_definition.Settings.Priority = NORMAL_PRIORITY_CLASS
task_definition.Settings.StopIfGoingOnBatteries = False
task_definition.Settings.UseUnifiedSchedulingEngine = True

exec_action = task_definition.Actions.Create(TASK_ACTION_EXEC)
exec_action.Path = sys.executable
exec_action.WorkingDirectory = os.getcwd()
exec_action.Arguments = '$(Arg0)' # $(Arg0)会在任务执行时被替换

task_name = f'{os.path.splitext(os.path.basename(sys.executable))[0]}_skip_uac'
root_folder = schedule_service.GetFolder('\\')
root_folder.RegisterTaskDefinition(
task_name,
task_definition,
TASK_CREATE_OR_UPDATE,
None,
None,
TASK_LOGON_INTERACTIVE_TOKEN,
)

执行计划任务

同样的,通过com组件ITaskService启动计划任务。此时就不需要管理员权限了。

1
2
3
exist_task = root_folder.GetTask(f'{task_name}')  # 如果任务不存在,此处会抛出异常
start_args = ['banana', 'kuma~']
exist_task.Run(' '.join(f'"{arg}"' for arg in start_args)) # 参数会在计划任务启动时作为argv传入

最终流程

启动时检查当前进程是否拥有管理员权限

  • 如果没有管理员权限,则检查系统的计划任务服务中有没有注册对应的计划任务。
    • 如果没有,则使用ShellExecute/ShellExecuteEx的方式,启动带管理员权限的进程(会弹出UAC对话框),并退出当前进程。
    • 如果有,则启动对应的计划任务,并退出当前进程
  • 如果有管理员权限,则检查系统的计划任务服务中有没有注册对应的计划任务。
    如果不存在,则需要创建/修复计划任务。因为此时程序已经拥有管理员权限,可以直接创建对应的计划任务

其他

方案还需要实现一些辅助的功能,这边也一并列出对应的Python实现

检查当前进程是否有管理员权限

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
def check_privileges() -> bool
class SID_IDENTIFIER_AUTHORITY(ctypes.Structure):
_fields_ = [
("byte0", ctypes.c_byte),
("byte1", ctypes.c_byte),
("byte2", ctypes.c_byte),
("byte3", ctypes.c_byte),
("byte4", ctypes.c_byte),
("byte5", ctypes.c_byte),
]

nt_authority = SID_IDENTIFIER_AUTHORITY()
nt_authority.byte5 = SECURITY_NT_AUTHORITY

administrators_group = ctypes.c_void_p()
if ctypes.windll.advapi32.AllocateAndInitializeSid(
ctypes.byref(nt_authority),
2,
SECURITY_BUILTIN_DOMAIN_RID,
DOMAIN_ALIAS_RID_ADMINS,
0,
0,
0,
0,
0,
0,
ctypes.byref(administrators_group),
):
is_admin = BOOL()
if ctypes.windll.advapi32.CheckTokenMembership(
None,
administrators_group,
ctypes.byref(is_admin),
):
result = is_admin.value != 0
else:
# CheckTokenMembership错误,视为无管理员权限
result = False
ctypes.windll.advapi32.FreeSid(administrators_group)
else:
# AllocateAndInitializeSid错误,视为无管理员权限
result = False
return result

使用ShellExecuteEx启动带管理员权限的进程

1
2
3
4
5
6
7
8
9
10
11
12
13
start_args = ['banana', 'kuma~']
ShellExecuteEx(
# 进程启动后的表现
nShow=SW_HIDE,
# 以管理员权限启动
lpVerb='runas',
# 可执行程序的路径
lpFile=sys.executable,
# 传入的参数,注意所有的参数都需要以双引号包起来
lpParameters=' '.join(f'"{arg}"' for arg in start_args),
# 工作目录
lpDirectory=os.getcwd(),
)

Python3在Windows系统上对长路径的支持

Windows XP及之后的版本

maximum-path-length-limitation中提到,可以将路径转换为extended-length path再传入相关API。具体转换方式为

  1. 将路径中的斜杠(/)替换为反斜杠(\)
  2. 在路径前增加前缀\\?\
  3. 如果是UNC路径,需要删除开头的\\,并增加\\?\UNC\前缀。如\\server\share需要转换为\\?\UNC\server\share
    该方法最长可支持32767左右的路径长度。
    可以将extended-length path作为路径直接传入open函数来打开文件。但os.path.walk、os.path.join等路径相关的函数在处理extended-length path时会和预期的效果不太一样。建议在代码中始终传递原始路径,自己封装open函数,将filename参数转换为extended-length path并传入真正的open函数中。

Windows 10 1607及之后的版本

enable-long-paths-in-windows-10-version-1607-and-later中提到,Windows 10 1607及之后的版本, 在满足注册表或组策略中启用对长路径的支持,且应用程序本身的Mainfset中标识支持长路径的前提下,应用程序可直接打开拥有长路径的文件。
Python3.6(changlist)开始,可以用这种方式支持长路径。代码中可以直接通过open(long_path)的方式读写拥有长路径的文件。

PyInstaller相关

使用extended-length path方案的代码,在使用PyInstaller生成可执行程序后依然可以正常工作
而使用第二种方案的代码,在使用PyInstaller(截止到v3.5)生成可执行程序之后,需要修改Mainfset才能正常工作。
Mainfset本质上是一个xml描述文件。描述可执行程序或动态库的元数据。包括依赖的动态库版本、支持的特性等。可以作为资源文件内嵌,也可以是独立的文件。
使用方案二支持长路径,要求应用程序的Mainfset中包含 longPathAware 元素
Python3.6及之后的python.exe,内嵌Mainfset已经包含longPathAware元素。而PyInstaller在Windows上生成的程序,Mainfset在独立的.mainfset文件中,且内部没有包含longPathAware元素。需要修改.mainfset文件,增加元素,才能正常工作