2014年8月31日日曜日

subprocess.Popen() で子プロセスにファイルディスクリプタを渡す方法について

Python から sshpass を実行する時に安全にパスワードを渡す方法について 」では Python で sshpass にファイルディスクリプタ経由でパスワードを渡す方法も紹介しました。

その記事にも書いてますが、Python3 でちゃんとパスワードを渡せるようになるまでにかなり苦労し、少し詳しくなれたので忘れないように記事にしときます。

まず、 http://pydoc.net/Python/ansible/0.9/ansible.runner.connection_plugins.ssh/ に Python2 系と思われるコードが掲載されています。これを参考に以下のサンプルコードを書いてみました。

import getpass
import os
import subprocess

iPipe_r, iPipe_w = os.pipe()

oSub = subprocess.Popen(
    ['sshpass', '-d', str(iPipe_r), 'ssh', 'localhost', 'hostname'],
    stdout=subprocess.PIPE)

sPasswd = getpass.getpass()
os.write(iPipe_w, '{}\n'.format(sPasswd).encode())

print(oSub.stdout.read().decode())

このサンプルコードは Python2.7 では期待通り動きます。

$ python2.7 SampleSshpass.py
Password:
TestServer

しかし、Python3.2 ではうまくいきません。

$ python3.2 SampleSshpass.py
Password: Permission denied, please try again.

os.pipe() で作成したパイプを使って sshpass にパスワードを渡そうとしてますが、うまくいってないようです。

Python2.7 と Python3.2 の subprocess.Popen() のドキュメントを見比べてみると引数の close_fds のデフォルト値が Python2.7 では False ですが、Python3.2 では True になっていました。

ドキュメントの close_fds の説明を見ると Linux では以下のようです。 (Windows はこれとは違うようです)

If close_fds is true, all file descriptors
except 0, 1 and 2 will be closed before the child
process is executed. (POSIX only).

close_fds が True の場合は、子プロセス (今回の場合は sshpass) が実行される前に 0 (標準入力), 1 (標準出力), 2 (標準エラー出力) 以外のファイルディスクリプタは閉じられてしまうとのことです。

iPipe_r, iPipe_w = os.pipe() で作成したパイプを子プロセスの標準入力として渡していないので閉じられてしまっているようです。

なので、標準入力としてパイプのファイルディスクリプタを渡す以下のコードは Python3.2 でもうまく動きます。

import getpass
import os
import subprocess

iPipe_r, iPipe_w = os.pipe()

oSub = subprocess.Popen(
    ['sshpass', 'ssh', 'localhost', 'hostname'],
    stdout=subprocess.PIPE, stdin=iPipe_r)

sPasswd = getpass.getpass()
os.write(iPipe_w, '{}\n'.format(sPasswd).encode())

print(oSub.stdout.read().decode())

sshpass のオプションに -f, -d, -p, -e のどれも指定しないようにしたので、標準入力からパスワードが読み込まれます。

stdin=iPipe_r で標準入力としてパイプのファイルディスクリプタを渡しています。

また、標準入力として渡さなくても subprocess.Popen() の引数で close_fds=False としてやれば Python3.2 でもうまく動きます。

import getpass
import os
import subprocess

iPipe_r, iPipe_w = os.pipe()

oSub = subprocess.Popen(
    ['sshpass', '-d', str(iPipe_r), 'ssh', 'localhost', 'hostname'],
    stdout=subprocess.PIPE, close_fds=False)

sPasswd = getpass.getpass()
os.write(iPipe_w, '{}\n'.format(sPasswd).encode())

print(oSub.stdout.read().decode())
しかし、このコードは Python3.4 では動きません。。。
$ python3.4 SampleSshpass2.py
Password: Permission denied, please try again.

先程と同様のエラー内容からして、パイプのファイルディスクリプタが閉じられているように思われます。

Python3.4 のドキュメントでそれらしいところが無いか探してみると、os.pipe() の説明が以下のようになっていました。

os.pipe()
    Create a pipe. Return a pair of file descriptors (r, w)
    usable for reading and writing, respectively.
    The new file descriptor is non-inheritable.

    Availability: Unix, Windows.

    Changed in version 3.4: The new file descriptors
    are now non-inheritable.

non-inheritable の説明は ここ にあります。

ファイルディスクリプタには inheritable というフラグがあり、このフラグが False の場合は子プロセス実行時に OS レベルでこのファイルディスクリプタが閉じられるようです。

Python3.4 より前のバージョンでは os.pipe() で作成されるパイプの inheritable フラグは True でしたが、Python3.4 では False になったとのことです。

作成したパイプが inheritable かどうかは以下のように調べます

$ python3.4
>>> import os
>>> iPipe_r, iPipe_w = os.pipe()
>>> os.get_inheritable(iPipe_r)
False

パイプを os.set_inheritable() で inheritable に変更すればいいので、一応以下のコードで sshpass できます。

import getpass
import os
import subprocess

iPipe_r, iPipe_w = os.pipe()
os.set_inheritable(iPipe_r, True)

oSub = subprocess.Popen(
    ['sshpass', '-d', str(iPipe_r), 'ssh', 'localhost', 'hostname'],
    stdout=subprocess.PIPE, close_fds=False)

sPasswd = getpass.getpass()
os.write(iPipe_w, '{}\n'.format(sPasswd).encode())

print(oSub.stdout.read().decode())

しかし、os.set_inheritable() は Python3.4 で新たに追加された関数なので Python3.2 とかでは動かず、あまり好ましくありません。

もう少しドキュメントを読んでみると subprocess.Popen() の pass_fds という引数がありました。この引数で指定されたファイルディスクリプタは子プロセス起動時に閉じられないとのことです。

ということで、最終的にコードは以下のようになりました。

import getpass
import os
import subprocess

iPipe_r, iPipe_w = os.pipe()

oSub = subprocess.Popen(
    ['sshpass', '-d', str(iPipe_r), 'ssh', 'localhost', 'hostname'],
    stdout=subprocess.PIPE, pass_fds=(iPipe_r,))

sPasswd = getpass.getpass()
os.write(iPipe_w, '{}\n'.format(sPasswd).encode())

print(oSub.stdout.read().decode())

Python3.2 でも、3.4 でも動いてます。

おまけですが、subprocess.check_output() は ドキュメントでは引数は

subprocess.check_output(args, *, input=None, stdin=None, stderr=None,
        shell=False, universal_newlines=False, timeout=None)

となっていて、pass_fds がありませんが、もう少しドキュメントを読み進めると subprocess.Popen() の stdout 引数以外は使えるようです。なので、subprocess.check_output() の場合は以下のコードとなります。

import getpass
import os
import subprocess

iPipe_r, iPipe_w = os.pipe()

sPasswd = getpass.getpass()
os.write(iPipe_w, '{}\n'.format(sPasswd).encode())

bOutput = subprocess.check_output(
    ['sshpass', '-d', str(iPipe_r), 'ssh', 'localhost', 'hostname'],
    pass_fds=(iPipe_r,))

print(bOutput.decode())

0 件のコメント:

コメントを投稿