python 如果需要多个 stdin 输入,asyncio 会死锁
python asyncio gets deadlock if multiple stdin input is needed
我编写了一个命令行工具来使用 python asyncio 为多个 git 存储库执行 git pull
。如果所有 repos 都具有 ssh 无密码登录设置,它就可以正常工作。如果只有 1 个 repo 需要输入密码,它也可以正常工作。当多个仓库需要输入密码时,似乎会死锁。
我的实现很简单。主要逻辑是
utils.exec_async_tasks(
utils.run_async(path, cmds) for path in repos.values())
其中 run_async
创建并等待子进程调用,exec_async_tasks
运行所有任务。
async def run_async(path: str, cmds: List[str]):
"""
Run `cmds` asynchronously in `path` directory
"""
process = await asyncio.create_subprocess_exec(
*cmds, stdout=asyncio.subprocess.PIPE, cwd=path)
stdout, _ = await process.communicate()
stdout and print(stdout.decode())
def exec_async_tasks(tasks: List[Coroutine]):
"""
Execute tasks asynchronously
"""
# TODO: asyncio API is nicer in python 3.7
if platform.system() == 'Windows':
loop = asyncio.ProactorEventLoop()
asyncio.set_event_loop(loop)
else:
loop = asyncio.get_event_loop()
try:
loop.run_until_complete(asyncio.gather(*tasks))
finally:
loop.close()
完整的代码库是 here on github。
我认为问题如下。在run_async
、asyncio.create_subprocess_exec
中,stdin没有重定向,所有子进程(repos)都使用系统的stdin。当第一个 repo 要求输入密码时,asyncio scheduler 会看到一个阻塞输入,并在等待命令行输入时切换到第二个 repo。但是,如果第二个仓库在第一个仓库的密码输入完成之前要求输入密码,则系统的标准输入将链接到第二个仓库。第一个回购将永远等待输入。
我不知道如何处理这种情况。我是否必须为每个子进程重定向标准输入?如果一些存储库有无密码登录而有些没有怎么办?
部分思路如下
在 create_subprocess_exec
中检测何时需要输入密码。如果是,则调用 input()
并将其结果传递给 process.communicate(input)
。但是我怎样才能即时检测到它呢?
检测哪些存储库需要输入密码,并将它们从异步执行中排除。最好的方法是什么?
在默认配置中,当需要用户名或密码时,git
将directly access the /dev/tty
synonym更好地控制'controlling'终端设备,例如让您与用户互动的设备。由于子进程默认从其父进程继承控制终端,因此您启动的所有 git 进程都将访问同一个 TTY 设备。所以是的,当尝试读取和写入同一个 TTY 时,进程会破坏彼此的预期输入,它们会挂起。
防止这种情况发生的一种简单方法是为每个子进程提供自己的会话;每个不同的会话都有不同的控制 TTY。通过设置 start_new_session=True
:
process = await asyncio.create_subprocess_exec(
*cmds, stdout=asyncio.subprocess.PIPE, cwd=path, start_new_session=True)
您无法真正预先确定哪些 git 命令可能需要用户凭据,因为 git 可以配置为从整个位置范围获取凭据,并且这些仅在远程存储库实际挑战身份验证时使用。
更糟糕的是,对于 ssh://
远程 URL,git 根本不处理身份验证,而是将其留给它打开的 ssh
客户端进程。更多内容见下文。
但是 Git 如何要求凭据(除 ssh
之外的任何凭据)是可配置的;见gitcredentials documentation。如果您的代码必须能够将凭据请求转发给最终用户,则可以使用它。我不会将其留给 git 命令通过终端执行此操作,因为用户将如何知道特定的 git 命令将接收哪些凭据,更不用说您遇到的问题了确保提示按逻辑顺序到达。
相反,我会通过您的脚本路由所有凭据请求。您有两种选择:
设置 GIT_ASKPASS
环境变量,指向每个提示 git 应该 运行 的可执行文件。
此可执行文件使用单个参数调用,向用户显示提示。为给定凭证所需的每条信息单独调用它,因此为用户名(如果不知道)和密码。提示文本应该让用户清楚地知道所要求的内容(例如 "Username for 'https://github.com': "
或 "Password for 'https://someusername@github.com': "
.
注册一个credential helper;这是作为 shell 命令执行的(因此可以有自己的预配置命令行参数),还有一个额外的参数告诉助手它需要什么样的操作。如果将 get
作为最后一个参数传递,则要求它提供给定主机和协议的凭据,或者可以告诉它某些凭据是成功的 store
,或者被拒绝 erase
。在所有情况下,它都可以从 stdin 读取信息以了解主机 git 正在尝试以多行 key=value
格式进行身份验证。
因此,使用凭据帮助程序,您可以一步提示输入用户名和密码组合 ,并且您还可以获得有关该过程的更多信息;处理 store
和 erase
操作可以让您更有效地缓存凭据。
Git fill first ask each configured credential helper, in config order (see the FILES
section to understand how the 4 config file locations are processed in order). You can add a new one-off helper configuration on the git
command line with the -c credential.helper=...
command-line switch, which is added to the end. If no credential helper was able to fill in a missing username or password, then the user is prompted with GIT_ASKPASS
or the other prompting options.
对于 SSH 连接,git 创建一个新的 ssh
子进程。然后 SSH 将处理身份验证,并可能要求用户提供凭据,或者对于 ssh 密钥,要求用户提供密码。这将再次通过 /dev/tty
完成,而 SSH 对此更加顽固。虽然您可以将 SSH_ASKPASS
环境变量设置为用于提示的二进制文件,但 SSH 将仅使用此 if there is no TTY session and DISPLAY
is also set.
SSH_ASKPASS
必须是可执行文件(因此不能传入参数),并且您不会收到提示凭据成功或失败的通知。
我还要确保将当前环境变量复制到子进程,因为如果用户设置了 SSH 密钥代理来缓存 ssh 密钥,您会希望 SSH 进程 git 开始使用它们;通过环境变量发现密钥代理。
因此,要为凭据助手创建连接,并且该助手也适用于 SSH_ASKPASS
,您可以使用一个简单的同步脚本,从环境变量中获取套接字:
#!/path/to/python3
import os, socket, sys
path = os.environ['PROMPTING_SOCKET_PATH']
operation = sys.argv[1]
if operation not in {'get', 'store', 'erase'}:
operation, params = 'prompt', f'prompt={operation}\n'
else:
params = sys.stdin.read()
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as s:
s.connect(path)
s.sendall(f'''operation={operation}\n{params}'''.encode())
print(s.recv(2048).decode())
这应该设置了可执行位。
然后可以将其作为临时文件或预先构建的文件传递给 git 命令,然后在 PROMPTING_SOCKET_PATH
环境变量中添加 Unix 域套接字路径。它可以兼作 SSH_ASKPASS
提示器,将操作设置为 prompt
.
此脚本然后使 SSH 和 git 向您的 UNIX 域套接字服务器询问用户凭据,每个用户在一个单独的连接中。我使用了很大的接收缓冲区大小,我认为您 运行 与此协议的交换不会超过它,我也看不出有任何理由让它不足。它使脚本简洁明了。
您可以将其用作 GIT_ASKPASS
命令,但这样您就无法获得有关非 ssh 连接凭据成功的有价值信息。
这是一个 UNIX 域套接字服务器的演示实现,它处理 git 和来自上述凭证助手的凭证请求,它只生成随机的十六进制值而不是询问用户:
import asyncio
import os
import secrets
import tempfile
async def handle_git_prompt(reader, writer):
data = await reader.read(2048)
info = dict(line.split('=', 1) for line in data.decode().splitlines())
print(f"Received credentials request: {info!r}")
response = []
operation = info.pop('operation', 'get')
if operation == 'prompt':
# new prompt for a username or password or pass phrase for SSH
password = secrets.token_hex(10)
print(f"Sending prompt response: {password!r}")
response.append(password)
elif operation == 'get':
# new request for credentials, for a username (optional) and password
if 'username' not in info:
username = secrets.token_hex(10)
print(f"Sending username: {username!r}")
response.append(f'username={username}\n')
password = secrets.token_hex(10)
print(f"Sending password: {password!r}")
response.append(f'password={password}\n')
elif operation == 'store':
# credentials were used successfully, perhaps store these for re-use
print(f"Credentials for {info['username']} were approved")
elif operation == 'erase':
# credentials were rejected, if we cached anything, clear this now.
print(f"Credentials for {info['username']} were rejected")
writer.write(''.join(response).encode())
await writer.drain()
print("Closing the connection")
writer.close()
await writer.wait_closed()
async def main():
with tempfile.TemporaryDirectory() as dirname:
socket_path = os.path.join(dirname, 'credential.helper.sock')
server = await asyncio.start_unix_server(handle_git_prompt, socket_path)
print(f'Starting a domain socket at {server.sockets[0].getsockname()}')
async with server:
await server.serve_forever()
asyncio.run(main())
请注意,凭据助手还可以将 quit=true
或 quit=1
添加到输出中,以告知 git 不要寻找任何其他凭据助手并且不再提示。
您可以使用 git credential <operation>
command 来测试凭据助手是否有效,方法是使用 git -c credential.helper=...
命令传入助手脚本 (/full/path/to/credhelper.py
)-行选项。 git credential
可以在标准输入上接受一个 url=...
字符串,它会像 git 那样解析它来联系凭证助手;有关完整的交换格式规范,请参阅文档。
首先,在单独的终端中启动上面的演示脚本:
$ /usr/local/bin/python3.7 git-credentials-demo.py
Starting a domain socket at /tmp/credhelper.py /var/folders/vh/80414gbd6p1cs28cfjtql3l80000gn/T/tmprxgyvecj/credential.helper.sock
然后尝试从中获取凭据;我也包含了 store
和 erase
操作的演示:
$ export PROMPTING_SOCKET_PATH="/var/folders/vh/80414gbd6p1cs28cfjtql3l80000gn/T/tmprxgyvecj/credential.helper.sock"
$ CREDHELPER="/tmp/credhelper.py"
$ echo "url=https://example.com:4242/some/path.git" | git -c "credential.helper=$CREDHELPER" credential fill
protocol=https
host=example.com:4242
username=5b5b0b9609c1a4f94119
password=e259f5be2c96fed718e6
$ echo "url=https://someuser@example.com/some/path.git" | git -c "credential.helper=$CREDHELPER" credential fill
protocol=https
host=example.com
username=someuser
password=766df0fba1de153c3e99
$ printf "protocol=https\nhost=example.com:4242\nusername=5b5b0b9609c1a4f94119\npassword=e259f5be2c96fed718e6" | git -c "credential.helper=$CREDHELPER" credential approve
$ printf "protocol=https\nhost=example.com\nusername=someuser\npassword=e259f5be2c96fed718e6" | git -c "credential.helper=$CREDHELPER" credential reject
然后当您查看示例脚本的输出时,您会看到:
Received credentials request: {'operation': 'get', 'protocol': 'https', 'host': 'example.com:4242'}
Sending username: '5b5b0b9609c1a4f94119'
Sending password: 'e259f5be2c96fed718e6'
Closing the connection
Received credentials request: {'operation': 'get', 'protocol': 'https', 'host': 'example.com', 'username': 'someuser'}
Sending password: '766df0fba1de153c3e99'
Closing the connection
Received credentials request: {'operation': 'store', 'protocol': 'https', 'host': 'example.com:4242', 'username': '5b5b0b9609c1a4f94119', 'password': 'e259f5be2c96fed718e6'}
Credentials for 5b5b0b9609c1a4f94119 were approved
Closing the connection
Received credentials request: {'operation': 'erase', 'protocol': 'https', 'host': 'example.com', 'username': 'someuser', 'password': 'e259f5be2c96fed718e6'}
Credentials for someuser were rejected
Closing the connection
请注意如何为 protocol
和 host
提供已解析的字段集,并省略路径;如果您设置 git 配置选项 credential.useHttpPath=true
(或者已经为您设置),那么 path=some/path.git
将添加到传入的信息中。
对于 SSH,只需调用可执行文件并提示显示:
$ $CREDHELPER "Please enter a super-secret passphrase: "
30b5978210f46bb968b2
并且演示服务器已打印:
Received credentials request: {'operation': 'prompt', 'prompt': 'Please enter a super-secret passphrase: '}
Sending prompt response: '30b5978210f46bb968b2'
Closing the connection
只需确保在启动 git 进程时仍然设置 start_new_session=True
以确保 SSH 被强制使用 SSH_ASKPASS
.
env = {
os.environ,
SSH_ASKPASS='../path/to/credhelper.py',
DISPLAY='dummy value',
PROMPTING_SOCKET_PATH='../path/to/domain/socket',
}
process = await asyncio.create_subprocess_exec(
*cmds, stdout=asyncio.subprocess.PIPE, cwd=path,
start_new_session=True, env=env)
当然,您随后如何处理提示用户是一个单独的问题,但您的脚本现在具有完全控制权(每个 git
命令将耐心等待凭据助手 return 所请求的信息)并且您可以排队请求以供用户填写,并且您可以根据需要缓存凭据(以防多个命令都在等待同一主机的凭据)。
一般来说,向 git 提供密码的推荐方式是通过 "credential helpers" 或 GIT_ASKPASS
,正如 、 所指出的但是对于Git+SSH,情况就复杂了(下面会有更多讨论)。因此很难在 OS 中正确设置它。
如果您只想快速修补您的脚本,这里是适用于 Linux 和 Windows 的代码:
async def run_async(...):
...
process = await asyncio.create_subprocess_exec( *cmds,
stdin=asyncio.subprocess.PIPE,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
start_new_session=True, cwd=path)
stdout, stderr = await process.communicate(password + b'\n')
参数start_new_session=True
将为子进程设置一个新的SID,以便为它分配一个新的会话which have no controlling TTY by default。
然后 SSH 将被强制从 stdin
管道读取密码。
在 Windows 上,start_new_session
似乎没有效果(在 Windows AFAIK 上没有 SID 的概念)。
除非您计划在您的项目 "gita" 中实施 Git-credential-manager (GCM),否则我根本不建议向 Git 提供任何密码( unix philosophy)。只需设置 stdin=asyncio.subprocess.DEVNULL
并将 None
传递给 process.communicate()
。这将强制 Git 和 SSH 使用现有的 CM 或中止(您可以稍后处理错误)。
而且,我觉得"gita"不想搞乱其他CM的配置,比如GCM for windows。因此,不要费心去触摸 GIT_ASKPASS
或 SSH_ASKPASS
变量,或任何 credential.*
配置。为每个 repo 设置适当的 GCM 是用户的责任(和自由)。通常 Git 发行版已经包含 GCM 或 ASKPASS 实现。
讨论
这个问题有一个普遍的误解:Git没有打开TTY输入密码,SSH可以!实际上,其他与 ssh 相关的实用程序,例如 rsync
和 scp
,共享相同的行为(几个月前我在调试 SELinux 相关问题时很难解决这个问题) .验证见附件
因为Git调用SSH作为一个子进程,它无法知道SSH是否会打开TTY。 Git 可配置项,例如 core.askpass
或 GIT_ASKPASS
,将 不会 阻止 SSH 打开 /dev/tty
,至少对我来说不会在 CentOS 7 上使用 Git 1.8.3 进行测试(详见附录)。在两种常见情况下,您应该会收到密码提示:
- 服务器需要密码验证;
- 对于public-密钥认证,私钥存储(在本地文件
~/.ssh/id_rsa
或PKCS11芯片中)受密码保护。
在这些情况下,ASKPASS 或 GCM 不会帮助您解决死锁问题。您必须禁用 TTY。
您可能还想阅读有关环境变量的信息 SSH_ASKPASS
。它指向满足以下条件时将调用的可执行文件:
- 当前会话没有可用的控制 TTY;
- 环境。变量
DISPLAY
已设置。
在Windows上,例如默认为SSH_ASKPASS=/mingw64/libexec/git-core/git-gui--askpass
。该程序随 main-stream distribution and the official Git-GUI 包一起提供。
因此,在 Windows 和 Linux 桌面环境中,如果您通过 start_new_session=True
禁用 TTY 并保持其他配置不变,SSH 将自动弹出 密码提示。
附录
要验证哪个进程打开 TTY,您可以在 Git 进程等待密码时 运行 ps -fo pid,tty,cmd
。
$ ps -fo pid,tty,cmd
3839452 pts/0 \_ git clone ssh://username@hostname/path/to/repo ./repo
3839453 pts/0 \_ ssh username@hostname git-upload-pack '/path/to/repo'
$ ls -l /proc/3839453/fd /proc/3839452/fd
/proc/3839452/fd:
total 0
lrwx------. 1 xxx xxx 64 Apr 4 21:45 0 -> /dev/pts/0
lrwx------. 1 xxx xxx 64 Apr 4 21:45 1 -> /dev/pts/0
lrwx------. 1 xxx xxx 64 Apr 4 21:43 2 -> /dev/pts/0
l-wx------. 1 xxx xxx 64 Apr 4 21:45 4 -> pipe:[49095162]
lr-x------. 1 xxx xxx 64 Apr 4 21:45 5 -> pipe:[49095163]
/proc/3839453/fd:
total 0
lr-x------. 1 xxx xxx 64 Apr 4 21:42 0 -> pipe:[49095162]
l-wx------. 1 xxx xxx 64 Apr 4 21:42 1 -> pipe:[49095163]
lrwx------. 1 xxx xxx 64 Apr 4 21:42 2 -> /dev/pts/0
lrwx------. 1 xxx xxx 64 Apr 4 21:42 3 -> socket:[49091282]
lrwx------. 1 xxx xxx 64 Apr 4 21:45 4 -> /dev/tty
我最终使用了@vincent 建议的简单解决方案,即通过设置 GIT_ASKPASS
环境变量禁用任何现有的密码机制,运行 在所有回购上异步,然后重新 运行同步失败的。
主要逻辑改为
cache = os.environ.get('GIT_ASKPASS')
os.environ['GIT_ASKPASS'] = 'echo'
errors = utils.exec_async_tasks(
utils.run_async(path, cmds) for path in repos.values())
# Reset context and re-run
if cache:
os.environ['GIT_ASKPASS'] = cache
else:
del os.environ['GIT_ASKPASS']
for path in errors:
if path:
subprocess.run(cmds, cwd=path)
在run_async
和exec_async_tasks
中,如果子进程执行失败,我只是重定向错误和return回购path
。
async def run_async(path: str, cmds: List[str]) -> Union[None, str]:
"""
Run `cmds` asynchronously in `path` directory. Return the `path` if
execution fails.
"""
process = await asyncio.create_subprocess_exec(
*cmds,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
cwd=path)
stdout, stderr = await process.communicate()
stdout and print(stdout.decode())
if stderr:
return path
你可以看到 this pull request 的完整变化。
进一步更新
上面的 PR 解决了当 https type remote 需要 username/password 输入时的问题,但是当 ssh 需要为多个 repos 输入密码时仍然有问题。感谢@gdlmx 下面的评论。
在 0.9.1 版本中,我基本上遵循了@gdlmx 的建议:在异步模式下 运行ning 时完全禁用用户输入,失败的回购将 运行 再次使用 subprocess
连载。
我编写了一个命令行工具来使用 python asyncio 为多个 git 存储库执行 git pull
。如果所有 repos 都具有 ssh 无密码登录设置,它就可以正常工作。如果只有 1 个 repo 需要输入密码,它也可以正常工作。当多个仓库需要输入密码时,似乎会死锁。
我的实现很简单。主要逻辑是
utils.exec_async_tasks(
utils.run_async(path, cmds) for path in repos.values())
其中 run_async
创建并等待子进程调用,exec_async_tasks
运行所有任务。
async def run_async(path: str, cmds: List[str]):
"""
Run `cmds` asynchronously in `path` directory
"""
process = await asyncio.create_subprocess_exec(
*cmds, stdout=asyncio.subprocess.PIPE, cwd=path)
stdout, _ = await process.communicate()
stdout and print(stdout.decode())
def exec_async_tasks(tasks: List[Coroutine]):
"""
Execute tasks asynchronously
"""
# TODO: asyncio API is nicer in python 3.7
if platform.system() == 'Windows':
loop = asyncio.ProactorEventLoop()
asyncio.set_event_loop(loop)
else:
loop = asyncio.get_event_loop()
try:
loop.run_until_complete(asyncio.gather(*tasks))
finally:
loop.close()
完整的代码库是 here on github。
我认为问题如下。在run_async
、asyncio.create_subprocess_exec
中,stdin没有重定向,所有子进程(repos)都使用系统的stdin。当第一个 repo 要求输入密码时,asyncio scheduler 会看到一个阻塞输入,并在等待命令行输入时切换到第二个 repo。但是,如果第二个仓库在第一个仓库的密码输入完成之前要求输入密码,则系统的标准输入将链接到第二个仓库。第一个回购将永远等待输入。
我不知道如何处理这种情况。我是否必须为每个子进程重定向标准输入?如果一些存储库有无密码登录而有些没有怎么办?
部分思路如下
在
create_subprocess_exec
中检测何时需要输入密码。如果是,则调用input()
并将其结果传递给process.communicate(input)
。但是我怎样才能即时检测到它呢?检测哪些存储库需要输入密码,并将它们从异步执行中排除。最好的方法是什么?
在默认配置中,当需要用户名或密码时,git
将directly access the /dev/tty
synonym更好地控制'controlling'终端设备,例如让您与用户互动的设备。由于子进程默认从其父进程继承控制终端,因此您启动的所有 git 进程都将访问同一个 TTY 设备。所以是的,当尝试读取和写入同一个 TTY 时,进程会破坏彼此的预期输入,它们会挂起。
防止这种情况发生的一种简单方法是为每个子进程提供自己的会话;每个不同的会话都有不同的控制 TTY。通过设置 start_new_session=True
:
process = await asyncio.create_subprocess_exec(
*cmds, stdout=asyncio.subprocess.PIPE, cwd=path, start_new_session=True)
您无法真正预先确定哪些 git 命令可能需要用户凭据,因为 git 可以配置为从整个位置范围获取凭据,并且这些仅在远程存储库实际挑战身份验证时使用。
更糟糕的是,对于 ssh://
远程 URL,git 根本不处理身份验证,而是将其留给它打开的 ssh
客户端进程。更多内容见下文。
但是 Git 如何要求凭据(除 ssh
之外的任何凭据)是可配置的;见gitcredentials documentation。如果您的代码必须能够将凭据请求转发给最终用户,则可以使用它。我不会将其留给 git 命令通过终端执行此操作,因为用户将如何知道特定的 git 命令将接收哪些凭据,更不用说您遇到的问题了确保提示按逻辑顺序到达。
相反,我会通过您的脚本路由所有凭据请求。您有两种选择:
设置
GIT_ASKPASS
环境变量,指向每个提示 git 应该 运行 的可执行文件。此可执行文件使用单个参数调用,向用户显示提示。为给定凭证所需的每条信息单独调用它,因此为用户名(如果不知道)和密码。提示文本应该让用户清楚地知道所要求的内容(例如
"Username for 'https://github.com': "
或"Password for 'https://someusername@github.com': "
.注册一个credential helper;这是作为 shell 命令执行的(因此可以有自己的预配置命令行参数),还有一个额外的参数告诉助手它需要什么样的操作。如果将
get
作为最后一个参数传递,则要求它提供给定主机和协议的凭据,或者可以告诉它某些凭据是成功的store
,或者被拒绝erase
。在所有情况下,它都可以从 stdin 读取信息以了解主机 git 正在尝试以多行key=value
格式进行身份验证。因此,使用凭据帮助程序,您可以一步提示输入用户名和密码组合 ,并且您还可以获得有关该过程的更多信息;处理
store
和erase
操作可以让您更有效地缓存凭据。
Git fill first ask each configured credential helper, in config order (see the FILES
section to understand how the 4 config file locations are processed in order). You can add a new one-off helper configuration on the git
command line with the -c credential.helper=...
command-line switch, which is added to the end. If no credential helper was able to fill in a missing username or password, then the user is prompted with GIT_ASKPASS
or the other prompting options.
对于 SSH 连接,git 创建一个新的 ssh
子进程。然后 SSH 将处理身份验证,并可能要求用户提供凭据,或者对于 ssh 密钥,要求用户提供密码。这将再次通过 /dev/tty
完成,而 SSH 对此更加顽固。虽然您可以将 SSH_ASKPASS
环境变量设置为用于提示的二进制文件,但 SSH 将仅使用此 if there is no TTY session and DISPLAY
is also set.
SSH_ASKPASS
必须是可执行文件(因此不能传入参数),并且您不会收到提示凭据成功或失败的通知。
我还要确保将当前环境变量复制到子进程,因为如果用户设置了 SSH 密钥代理来缓存 ssh 密钥,您会希望 SSH 进程 git 开始使用它们;通过环境变量发现密钥代理。
因此,要为凭据助手创建连接,并且该助手也适用于 SSH_ASKPASS
,您可以使用一个简单的同步脚本,从环境变量中获取套接字:
#!/path/to/python3
import os, socket, sys
path = os.environ['PROMPTING_SOCKET_PATH']
operation = sys.argv[1]
if operation not in {'get', 'store', 'erase'}:
operation, params = 'prompt', f'prompt={operation}\n'
else:
params = sys.stdin.read()
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as s:
s.connect(path)
s.sendall(f'''operation={operation}\n{params}'''.encode())
print(s.recv(2048).decode())
这应该设置了可执行位。
然后可以将其作为临时文件或预先构建的文件传递给 git 命令,然后在 PROMPTING_SOCKET_PATH
环境变量中添加 Unix 域套接字路径。它可以兼作 SSH_ASKPASS
提示器,将操作设置为 prompt
.
此脚本然后使 SSH 和 git 向您的 UNIX 域套接字服务器询问用户凭据,每个用户在一个单独的连接中。我使用了很大的接收缓冲区大小,我认为您 运行 与此协议的交换不会超过它,我也看不出有任何理由让它不足。它使脚本简洁明了。
您可以将其用作 GIT_ASKPASS
命令,但这样您就无法获得有关非 ssh 连接凭据成功的有价值信息。
这是一个 UNIX 域套接字服务器的演示实现,它处理 git 和来自上述凭证助手的凭证请求,它只生成随机的十六进制值而不是询问用户:
import asyncio
import os
import secrets
import tempfile
async def handle_git_prompt(reader, writer):
data = await reader.read(2048)
info = dict(line.split('=', 1) for line in data.decode().splitlines())
print(f"Received credentials request: {info!r}")
response = []
operation = info.pop('operation', 'get')
if operation == 'prompt':
# new prompt for a username or password or pass phrase for SSH
password = secrets.token_hex(10)
print(f"Sending prompt response: {password!r}")
response.append(password)
elif operation == 'get':
# new request for credentials, for a username (optional) and password
if 'username' not in info:
username = secrets.token_hex(10)
print(f"Sending username: {username!r}")
response.append(f'username={username}\n')
password = secrets.token_hex(10)
print(f"Sending password: {password!r}")
response.append(f'password={password}\n')
elif operation == 'store':
# credentials were used successfully, perhaps store these for re-use
print(f"Credentials for {info['username']} were approved")
elif operation == 'erase':
# credentials were rejected, if we cached anything, clear this now.
print(f"Credentials for {info['username']} were rejected")
writer.write(''.join(response).encode())
await writer.drain()
print("Closing the connection")
writer.close()
await writer.wait_closed()
async def main():
with tempfile.TemporaryDirectory() as dirname:
socket_path = os.path.join(dirname, 'credential.helper.sock')
server = await asyncio.start_unix_server(handle_git_prompt, socket_path)
print(f'Starting a domain socket at {server.sockets[0].getsockname()}')
async with server:
await server.serve_forever()
asyncio.run(main())
请注意,凭据助手还可以将 quit=true
或 quit=1
添加到输出中,以告知 git 不要寻找任何其他凭据助手并且不再提示。
您可以使用 git credential <operation>
command 来测试凭据助手是否有效,方法是使用 git -c credential.helper=...
命令传入助手脚本 (/full/path/to/credhelper.py
)-行选项。 git credential
可以在标准输入上接受一个 url=...
字符串,它会像 git 那样解析它来联系凭证助手;有关完整的交换格式规范,请参阅文档。
首先,在单独的终端中启动上面的演示脚本:
$ /usr/local/bin/python3.7 git-credentials-demo.py
Starting a domain socket at /tmp/credhelper.py /var/folders/vh/80414gbd6p1cs28cfjtql3l80000gn/T/tmprxgyvecj/credential.helper.sock
然后尝试从中获取凭据;我也包含了 store
和 erase
操作的演示:
$ export PROMPTING_SOCKET_PATH="/var/folders/vh/80414gbd6p1cs28cfjtql3l80000gn/T/tmprxgyvecj/credential.helper.sock"
$ CREDHELPER="/tmp/credhelper.py"
$ echo "url=https://example.com:4242/some/path.git" | git -c "credential.helper=$CREDHELPER" credential fill
protocol=https
host=example.com:4242
username=5b5b0b9609c1a4f94119
password=e259f5be2c96fed718e6
$ echo "url=https://someuser@example.com/some/path.git" | git -c "credential.helper=$CREDHELPER" credential fill
protocol=https
host=example.com
username=someuser
password=766df0fba1de153c3e99
$ printf "protocol=https\nhost=example.com:4242\nusername=5b5b0b9609c1a4f94119\npassword=e259f5be2c96fed718e6" | git -c "credential.helper=$CREDHELPER" credential approve
$ printf "protocol=https\nhost=example.com\nusername=someuser\npassword=e259f5be2c96fed718e6" | git -c "credential.helper=$CREDHELPER" credential reject
然后当您查看示例脚本的输出时,您会看到:
Received credentials request: {'operation': 'get', 'protocol': 'https', 'host': 'example.com:4242'}
Sending username: '5b5b0b9609c1a4f94119'
Sending password: 'e259f5be2c96fed718e6'
Closing the connection
Received credentials request: {'operation': 'get', 'protocol': 'https', 'host': 'example.com', 'username': 'someuser'}
Sending password: '766df0fba1de153c3e99'
Closing the connection
Received credentials request: {'operation': 'store', 'protocol': 'https', 'host': 'example.com:4242', 'username': '5b5b0b9609c1a4f94119', 'password': 'e259f5be2c96fed718e6'}
Credentials for 5b5b0b9609c1a4f94119 were approved
Closing the connection
Received credentials request: {'operation': 'erase', 'protocol': 'https', 'host': 'example.com', 'username': 'someuser', 'password': 'e259f5be2c96fed718e6'}
Credentials for someuser were rejected
Closing the connection
请注意如何为 protocol
和 host
提供已解析的字段集,并省略路径;如果您设置 git 配置选项 credential.useHttpPath=true
(或者已经为您设置),那么 path=some/path.git
将添加到传入的信息中。
对于 SSH,只需调用可执行文件并提示显示:
$ $CREDHELPER "Please enter a super-secret passphrase: "
30b5978210f46bb968b2
并且演示服务器已打印:
Received credentials request: {'operation': 'prompt', 'prompt': 'Please enter a super-secret passphrase: '}
Sending prompt response: '30b5978210f46bb968b2'
Closing the connection
只需确保在启动 git 进程时仍然设置 start_new_session=True
以确保 SSH 被强制使用 SSH_ASKPASS
.
env = {
os.environ,
SSH_ASKPASS='../path/to/credhelper.py',
DISPLAY='dummy value',
PROMPTING_SOCKET_PATH='../path/to/domain/socket',
}
process = await asyncio.create_subprocess_exec(
*cmds, stdout=asyncio.subprocess.PIPE, cwd=path,
start_new_session=True, env=env)
当然,您随后如何处理提示用户是一个单独的问题,但您的脚本现在具有完全控制权(每个 git
命令将耐心等待凭据助手 return 所请求的信息)并且您可以排队请求以供用户填写,并且您可以根据需要缓存凭据(以防多个命令都在等待同一主机的凭据)。
一般来说,向 git 提供密码的推荐方式是通过 "credential helpers" 或 GIT_ASKPASS
,正如
async def run_async(...):
...
process = await asyncio.create_subprocess_exec( *cmds,
stdin=asyncio.subprocess.PIPE,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
start_new_session=True, cwd=path)
stdout, stderr = await process.communicate(password + b'\n')
参数start_new_session=True
将为子进程设置一个新的SID,以便为它分配一个新的会话which have no controlling TTY by default。
然后 SSH 将被强制从 stdin
管道读取密码。
在 Windows 上,start_new_session
似乎没有效果(在 Windows AFAIK 上没有 SID 的概念)。
除非您计划在您的项目 "gita" 中实施 Git-credential-manager (GCM),否则我根本不建议向 Git 提供任何密码( unix philosophy)。只需设置 stdin=asyncio.subprocess.DEVNULL
并将 None
传递给 process.communicate()
。这将强制 Git 和 SSH 使用现有的 CM 或中止(您可以稍后处理错误)。
而且,我觉得"gita"不想搞乱其他CM的配置,比如GCM for windows。因此,不要费心去触摸 GIT_ASKPASS
或 SSH_ASKPASS
变量,或任何 credential.*
配置。为每个 repo 设置适当的 GCM 是用户的责任(和自由)。通常 Git 发行版已经包含 GCM 或 ASKPASS 实现。
讨论
这个问题有一个普遍的误解:Git没有打开TTY输入密码,SSH可以!实际上,其他与 ssh 相关的实用程序,例如 rsync
和 scp
,共享相同的行为(几个月前我在调试 SELinux 相关问题时很难解决这个问题) .验证见附件
因为Git调用SSH作为一个子进程,它无法知道SSH是否会打开TTY。 Git 可配置项,例如 core.askpass
或 GIT_ASKPASS
,将 不会 阻止 SSH 打开 /dev/tty
,至少对我来说不会在 CentOS 7 上使用 Git 1.8.3 进行测试(详见附录)。在两种常见情况下,您应该会收到密码提示:
- 服务器需要密码验证;
- 对于public-密钥认证,私钥存储(在本地文件
~/.ssh/id_rsa
或PKCS11芯片中)受密码保护。
在这些情况下,ASKPASS 或 GCM 不会帮助您解决死锁问题。您必须禁用 TTY。
您可能还想阅读有关环境变量的信息 SSH_ASKPASS
。它指向满足以下条件时将调用的可执行文件:
- 当前会话没有可用的控制 TTY;
- 环境。变量
DISPLAY
已设置。
在Windows上,例如默认为SSH_ASKPASS=/mingw64/libexec/git-core/git-gui--askpass
。该程序随 main-stream distribution and the official Git-GUI 包一起提供。
因此,在 Windows 和 Linux 桌面环境中,如果您通过 start_new_session=True
禁用 TTY 并保持其他配置不变,SSH 将自动弹出
附录
要验证哪个进程打开 TTY,您可以在 Git 进程等待密码时 运行 ps -fo pid,tty,cmd
。
$ ps -fo pid,tty,cmd
3839452 pts/0 \_ git clone ssh://username@hostname/path/to/repo ./repo
3839453 pts/0 \_ ssh username@hostname git-upload-pack '/path/to/repo'
$ ls -l /proc/3839453/fd /proc/3839452/fd
/proc/3839452/fd:
total 0
lrwx------. 1 xxx xxx 64 Apr 4 21:45 0 -> /dev/pts/0
lrwx------. 1 xxx xxx 64 Apr 4 21:45 1 -> /dev/pts/0
lrwx------. 1 xxx xxx 64 Apr 4 21:43 2 -> /dev/pts/0
l-wx------. 1 xxx xxx 64 Apr 4 21:45 4 -> pipe:[49095162]
lr-x------. 1 xxx xxx 64 Apr 4 21:45 5 -> pipe:[49095163]
/proc/3839453/fd:
total 0
lr-x------. 1 xxx xxx 64 Apr 4 21:42 0 -> pipe:[49095162]
l-wx------. 1 xxx xxx 64 Apr 4 21:42 1 -> pipe:[49095163]
lrwx------. 1 xxx xxx 64 Apr 4 21:42 2 -> /dev/pts/0
lrwx------. 1 xxx xxx 64 Apr 4 21:42 3 -> socket:[49091282]
lrwx------. 1 xxx xxx 64 Apr 4 21:45 4 -> /dev/tty
我最终使用了@vincent 建议的简单解决方案,即通过设置 GIT_ASKPASS
环境变量禁用任何现有的密码机制,运行 在所有回购上异步,然后重新 运行同步失败的。
主要逻辑改为
cache = os.environ.get('GIT_ASKPASS')
os.environ['GIT_ASKPASS'] = 'echo'
errors = utils.exec_async_tasks(
utils.run_async(path, cmds) for path in repos.values())
# Reset context and re-run
if cache:
os.environ['GIT_ASKPASS'] = cache
else:
del os.environ['GIT_ASKPASS']
for path in errors:
if path:
subprocess.run(cmds, cwd=path)
在run_async
和exec_async_tasks
中,如果子进程执行失败,我只是重定向错误和return回购path
。
async def run_async(path: str, cmds: List[str]) -> Union[None, str]:
"""
Run `cmds` asynchronously in `path` directory. Return the `path` if
execution fails.
"""
process = await asyncio.create_subprocess_exec(
*cmds,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
cwd=path)
stdout, stderr = await process.communicate()
stdout and print(stdout.decode())
if stderr:
return path
你可以看到 this pull request 的完整变化。
进一步更新
上面的 PR 解决了当 https type remote 需要 username/password 输入时的问题,但是当 ssh 需要为多个 repos 输入密码时仍然有问题。感谢@gdlmx 下面的评论。
在 0.9.1 版本中,我基本上遵循了@gdlmx 的建议:在异步模式下 运行ning 时完全禁用用户输入,失败的回购将 运行 再次使用 subprocess
连载。