仕事ではテレワーク環境を作るためにsshuttle
を使っているのだけれど、中身はあまり読み込んでいないので、勉強のためにちょっと中身を読んでみたところ勉強になる箇所を見付けたのでメモ。
ssh.py
sshuttleは、踏み台となるホストに対してsshセッションを張り、それを介してパケットを転送していくのだけれど、そのブートストラップにあたる箇所には以下のようなコードがある。
(s1, s2) = socket.socketpair() def setup(): # runs in the child process s2.close() s1a, s1b = os.dup(s1.fileno()), os.dup(s1.fileno()) s1.close() debug2('executing: %r' % argv) p = ssubprocess.Popen(argv, stdin=s1a, stdout=s1b, preexec_fn=setup, close_fds=True, stderr=stderr) os.close(s1a) os.close(s1b) s2.sendall(content) s2.sendall(content2) return p, s2
この箇所が最終的に達成したいことは、リモートホスト上のSSHで起動したPythonスクリプトの標準入出力と接続されたsocketを作成することだ(なぜsocketかというと、一度接続が確立された後は、転送したいパケットをそのまま流していくためだ)。 これが如何に達成されるか、順を追って見ていく。
まず、(s1, s2) = socket.socketpair()
によってソケットペアが作成される。ソケットペアとは、どちらかにデータを投入したらもう片方から出てくるように繋がったソケットのこと。つまり、s1
にデータを入れるとs2
から受信できて、逆もまた然り。s1
とs2
のいずれも双方向である。
graph LR subgraph python s1 s2 s1 .-> s2 s2 .-> s1 end
次にsetup()
が定義されているが、これは子プロセスから呼ばれるのでいったん飛ばす。
そして、s1a, s1b = os.dup(s1.fileno()), os.dup(s1.fileno())
によって、s1
のファイルディスクリプタは複製される。なぜ2つに複製するのかは、のちほど判明する(ネタバレ: 入力と出力のために分業させるためだ ---- ソケットは双方向通信可能でも、ファイルはそうではない)。複製しても、ソケットペアのつながりは維持される。
graph LR subgraph python s1 s2 s1a s1b s1 .-> s2 s2 .-> s1 s1a .-> s2 s2 .-> s1a s1b .-> s2 s2 .-> s1b end
次に、s1.close()
によってs1
が閉じられる。使うのはs1a
とs1b
だけであり、s1
自体は使用せず、複製の雛形としてのみ使っているようだ。
graph LR subgraph python s2 s1a s1b s1a .-> s2 s2 .-> s1a s1b .-> s2 s2 .-> s1b end
そしてssh
プロセスが起動される。
p = ssubprocess.Popen(argv, stdin=s1a, stdout=s1b, preexec_fn=setup,
close_fds=True, stderr=stderr)
プロセス起動の常として、まずfork
が行われる。forkの際にstdin
とstdout
にそれぞれs1a
とs1b
とが結び付けられる。s1a
とs1b
は、sshした先のプロセスの入出力に繋ぐためだけのファイルディスクリプタなのである。
graph LR subgraph python s2 s1a s1b s1a .-> s2 s2 .-> s1a s1b .-> s2 s2 .-> s1b end subgraph python2 s2f[s2] s1af[STDIN] s1bf[STDOUT] s1af .-> s2f s2f .-> s1af s1bf .-> s2f s2f .-> s1bf end s1a --- s1af s1b --- s1bf s2 --- s2f
次に、preexec_fn=setup
の効力によって、fork先のpythonでsetup
が呼ばれる。setup
の定義は次の通り。
def setup(): # runs in the child process s2.close()
fork先のpythonでは、入出力用にs1a
とs1b
のみを使用しており、それと接続されたs2
は不要だからである。
graph LR subgraph python s2 s1a s1b s1a .-> s2 s2 .-> s1a s1b .-> s2 s2 .-> s1b end subgraph python2 s1af[STDIN] s1bf[STDOUT] end s1a --- s1af s1b --- s1bf
preexec_fn
の実行が終了すると、exec
が行われる。execする先のargv
は、ここでは明記していないが、["ssh" "python" "-c" "ここに色々とpythonのコードが入っている"]
だと考えてほしい。詳しくは、元コードを読んでほしい。
graph LR subgraph python s2 s1a s1b s1a .-> s2 s2 .-> s1a s1b .-> s2 s2 .-> s1b end subgraph ssh[ssh python -c ...] s1af[STDIN] s1bf[STDOUT] end s1a --- s1af s1b --- s1bf
fork元のpythonの側では、もはやs1a
/s1b
を使うことはないから、閉じてしまっている。
os.close(s1a) os.close(s1b)
s1a
とs1b
とはそれぞれ入力と出力だけを行うので、矢印を一部省略して、以下のような状態になる。
graph TB subgraph python s2 end subgraph ssh[ssh python -c ...] s1af[STDIN] s1bf[STDOUT] end s2 .-> s1af s1bf .-> s2
そして、s2.sendall
が行われる。これは、ssh先で起動したpythonスクリプトを初期化するための情報が格納されているデータで、内容は割愛する。要するに、ここで渡した内容はsshした先のpythonの標準入力に送られる。
graph TB subgraph python s2 end subgraph ssh[ssh python -c ...] s1af[STDIN] s1bf[STDOUT] end s2 -. blah blah .-> s1af s1bf .-> s2
最後にreturn p, s2
が呼ばれ、プロセスオブジェクトとソケットs2
が返されて終了。s2
は双方向通信可能なソケットなので、データを流し込めばsshした先のpythonの標準入力に送られるし、読み込めばsshした先の標準出力からのデータが得られる。
このブートストラップが終了した後は、ローカルホストでファイアウォールの初期化が行われ、あらかじめ指定したIPレンジ宛てのパケットをこのソケットへと流し込んでいくという処理がはじまる。
読んだ感想
最初は一見して意味不明だったが、forkとexecの順序、そしてファイルディスクリプタの基本を抑えていたので読み解くことができた。ファイルディスクリプタに関する知識がないと難しいと思う。
fdopen() を使う時に注意することは、socketpair() の結果が read() でも write() でも使えるのに対して、高水準入出力では、1つの FILE * では、入 力と出力のうち一方しかできないことである。よって、fdopen() の前に、 dup() により、入力と出力に別々のファイル記述子を割り当てておく必要があ る。
そして、s1
とs2
との順序が悪いと思う。サブプロセスにはs2
の側を渡したほうが分かりやすいと思うけれど、何か意図があるのだろうか・・・。
もう一つ気になったのが、forkするときのファイルディスクリプタの扱いである。pythonではforkするときはdupしたファイルディスクリプタは閉じられてしまう。
でもstdin/stdoutに指定することができているので、標準入出力に割り当てた場合は違うのかな、と思う。s2
はファイルディスクリプタではない(?)ので、手動で閉じる必要があるのだと思う。このあたりはよくわからなかった。