FTP クライアントを作ってみよう (5)

前へ << FTP クライアントを作ってみよう (4) モジュールを使ってみよう (1) >> 次へ

ファイル転送プログラム ftptrans

さて、そろそろまともな FTP クライアントを作ってみましょう。
  • 指定された FTP サーバからファイルを GET、
  • 指定された FTP サーバにファイルを PUT、
  • 指定された FTP サーバにファイルの一覧を取得
という機能を持つプログラム ftptrans を作成します。以下のような書式を受け入れます。

  • ファイルを GET
    foo.bar.com に ユーザ名 hoge、パスワード secret でログインし、 file.tar.gz を取得するには
    % ftptrans GET hoge@foo.bar.com:file.tar.gz -password secret
    
    とします。すると、指定のファイルを GET し、標準入力に書き出します。

  • ファイルを PUT
    foo.bar.com に ユーザ名 hoge、パスワード secret でログインし、 ローカルにある local.file を remote.file として PUT するには
    % ftptrans GET hoge@foo.bar.com:remote.file -password secret -in local.file
    
    とします。

  • anonymous FTP
    ftp://ftp.foo.com/pub/file.tar.gz を取得するには
    % ftptrans ftp://ftp.foo.com/pub/file.tar.gz
    
    とします。指定のファイルを GET し、標準入力に書き出します。

オプション・その他

  • -password PASSWORD
    パスワードを指定します。これを指定しないと、ユーザにパスワードを 入力するよう求めます。 なお、anonymous FTP の場合は自動的に設定されますので、パスワード設定は不要です。
  • -in INFILE
    入力ファイルを指定します。PUT するときに使います。 省略すると、標準入力から読み込みます。
  • -out INFILE
    入力ファイルを指定します。GET や anonymous FTP のときに使います。 省略すると、標準出力に書き出します。
  • -timeout SEC
    タイムアウトを設定します。-timeout 10 とすると、10秒経っても 処理が完了しない場合は、強制的に終了します。
  • -passive
    Passive モードにします。これを指定しないと Active モードになります。
  • -v
    デバッグ出力を行います。全ての FTP サーバへの出力・レスポンスを 標準エラー出力に表示します。
パスワード入力について、もう少し説明します。 -password オプションでパスワードを指定できますが、これは本質的に危険です。 なぜなら、他人が ps コマンドを実行すると、パスワードが見えてしまう可能性があるからです。 この対策として ftptrans では最初に $0 を書き換えているので、大抵の場合は 大丈夫ですが、ps を実行するタイミング次第では見えてしまうかもしれません。

-password オプションでパスワードを指定しなかった場合、

Password: 
というプロンプトを表示し、ユーザにパスワードの入力を促します。 パスワードの入力部分はエコーバックしません (それっぽいでしょ?)。 なお、このときパスワードは標準入力から読んでいるだけなので、
% echo YourPassword > password-file
% chmod 600 password-file
% cat password-file | ftptrans GET user@host:dir/file
とすることもできます。
GET や anonymous FTP で、
% ftptrans GET hoge@foo.bar.com:/dir/ -password secret
% ftptrans ftp://ftp.foo.com/pub/dir/
などと、ファイルの最後を `/' にすると、 ファイルでなくディレクトリの一覧を取得します。 具体的には、RETR コマンドでなく LIST コマンドを送信するわけです。
ファイルは絶対パス (フルパス) で指定してもよいですし、 ログインしたときのカレントディレクトリからの相対パスでもよいです。
% ftptrans GET hoge@foo.bar.com:/home/hoge/public_html/index.html -password secret
と、絶対パスで指定しても構いませんが、ログイン直後に カレントディレクトリがホームディレクトリになるなら、
% ftptrans GET hoge@foo.bar.com:public_html/index.html -password secret
でもいいです。

ftptrans の関数

ちょっと長めのプログラムなので、まずは下請け関数の説明をします。

ftptrans.pl

  241: sub send_command {
  242:     $verbose && print STDERR "$_[0]";
  243:     print COMMAND $_[0];
  244: }
引数で指定された文字列を、コマンドコネクション用のソケットに出力します。
  251: sub read_response {
  252:     $buf = <COMMAND>;
  253:     $verbose && print STDERR "--> $buf";
  254:     
  255:                                 # 複数行のレスポンス。
  256:     if ( $buf =~ m/^(\d\d\d)-/ ){
  257:         $return_code = $1;
  258:         while (<COMMAND>){
  259:             $verbose && print STDERR "--> $_";
  260:             $buf .= $_;
  261:             if ( /^$return_code / ){
  262:                 last;
  263:             }
  264:         }
  265:     }
  266:     return $buf;
  267: }
FTP サーバからのレスポンスを受け取ります。 前ページ で説明したように、 複数行のレスポンスが返された場合は、 全てのレスポンスを受け取ります。

この関数は受け取ったレスポンスを返します。 ユーザ認証のときなどは、この戻り値によって 認証が成功したか失敗したのかを判断します。


  274: sub client_work {
  275:     ($SOCK, $host, $port) = @_;
  276: 
  277:                                 # ホスト名を、IP アドレスの構造体に変換
  278:     unless ( $iaddr = inet_aton($host) ){
  279:         die "$host は存在しないホストです。$!";
  280:     }
  281: 
  282:                                 # ポート番号と IP アドレスをまとめて構造体に変換
  283:     $sock_addr = pack_sockaddr_in($port, $iaddr);
  284: 
  285:                                 # ソケット生成
  286:     socket($SOCK, PF_INET, SOCK_STREAM, 0)
  287:         || die "ソケットを生成できません。\n";
  288: 
  289:                                 # 指定のホストの指定の port に接続
  290:     connect($SOCK, $sock_addr)
  291:         || die "$hostname のポート $port に接続できません。\n";
  292: 
  293:                                 # ファイルハンドルをバッファリングしない
  294:     select($SOCK); $|=1; select(STDOUT);
  295: }
ソケット名、ホスト、ポート番号を渡すと、 指定のホスト・ポートに接続します。例えば、 client_work(SOCKET,'foo.bar.com',80) とすると、foo.bar.com の WWW サーバに接続します。

この関数は、

  • FTP サーバに接続するとき
  • Passive モードなら、データコネクションを生成するとき
に使われます。Active モードならば、client_work は一度しか呼ばれません。
  301: sub server_work {
  302: 
  303:                         # ソケット生成
  304:     socket(DATA_WAITING, PF_INET, SOCK_STREAM, 0)
  305:         || die "ソケットを生成できません。\n";
  306: 
  307:                         # ソケットオプション設定
  308:     setsockopt(DATA_WAITING, SOL_SOCKET, SO_REUSEADDR, 1)
  309:         || die "setsockopt でエラーが発生しました。\n";
  310: 
  311:                         # ソケットにアドレス(=名前)を割り付ける
  312:     bind(DATA_WAITING, pack_sockaddr_in(0, INADDR_ANY))
  313:         || die "bind に失敗しました。$!";
  314: 
  315:                         # OSに、クライアントと接続し待ち行列に入れるよう指示
  316:     listen(DATA_WAITING, SOMAXCONN)
  317:         || die "listen に失敗しました。$!";
  318: 
  319:                         # ローカルホストの IP アドレス・ポート番号を取得
  320:     $local_sock_addr = getsockname(COMMAND);
  321:     ($tmp_port, $local_addr) = unpack_sockaddr_in($local_sock_addr);
  322:     $local_ip = inet_ntoa($local_addr);
  323:     $local_ip =~ s/\./,/g;
  324: 
  325:                         # データコネクションのポート番号を取得
  326:     $local_sock_addr = getsockname(DATA_WAITING);
  327:     ($local_port, $tmp_addr) = unpack_sockaddr_in($local_sock_addr);
  328: 
  329:     return ($local_ip, $local_port);
  330: }
ポートを listen します。ポート番号の選択は OS にまかせます。 戻り値として、ローカルホストの IP アドレスとポート番号を返します。

この関数は、Active モードのときに呼ばれ、 FTP サーバからの接続を待つための下準備をします。

ftptrans の説明

さて、先頭から順に見ていきましょう。 既に基礎は説明してありますので、さらっと流します。
    6: BEGIN {
    7:     $0=~s|^\S+ (\S+)|$1|;
    8: }
$0 を書き換えます。例えば
% ftptrans GET user@host:file -password SECRET
と実行したとき、たまたま他のユーザが ps コマンドを実行すると、
% ps axww
 5812  p1  S+     0:00.18 /usr/local/bin/perl ftptrans.pl get user@host:file -password SECRET (perl5.00404)
と、パスワードが丸見えになってしまいます。これではまずいので、 $0 eq 'ftptrans.pl' となるように書き換えるのです。 できるだけ早くこの処理を行いたいため、BEGIN ブロックで囲んでいます。
perl はできるだけ早く (スクリプトの文法解析の途中であっても) BEGIN ブロックの中の処理を行おうとします。
ただし perl が実行されて、$0 を書き換えるまでの間に ps コマンドを 実行すると、もしかしたらパスワードが見えてしまうかもしれません。 -password オプションはセキュリティ的には完璧ではないと思って下さい。
以下は引数解析を行っている部分です。
   28: if ( ! @ARGV ){
   29:     &usage;
   30: }
   31: 
   32: for ( $i=0 ; $i<@ARGV ; $i++ ){
   33:     $_ = $ARGV[$i];
   34: 
   35:                                 # anonymous FTP
   36:     if ( m|^ftp://(.*)|i ){
   37:         if ( $1 =~ m|^([a-zA-Z0-9\.\-\_]+)(/?.*)$| ){
   38:             $hostname = $1;
   39:             $target_file = $2 || '/';
   40:         } else {
   41:             &usage;
   42:         }
   43: 
   44:         $mode = 'get';
   45:         $username = 'anonymous';
   46:                                 # パスワードを作成するために、
   47:                                 # ユーザ名とホスト名を取得
   48:         $local_username = `whoami`;   chop $local_username;
   49:         $local_hostname = `hostname`; chop $local_hostname;
   50:         $password = "$local_username\@$local_hostname";
   51: 
   52:     } elsif ( m/^GET|PUT$/i ){
   53:         $mode = $_;
   54:         $mode =~ tr/A-Z/a-z/;
   55: 
   56:         $_ = $ARGV[++$i];
   57: 
   58:         if ( m|^(.*?)@([a-zA-Z0-9\.\-\_]+):(.*)$| ){
   59:             ($username, $hostname, $target_file) = ($1, $2, $3);
   60:         } else {
   61:             &usage;
   62:         }
   63: 
   64:     } elsif ( m/^-password$/i ){
   65:         $password = $ARGV[++$i] || &usage;
   66: 
   67:     } elsif ( m/^-in$/i ){
   68:         $infile = $ARGV[++$i] || &usage;
   69: 
   70:     } elsif ( m/^-out$/i ){
   71:         $outfile = $ARGV[++$i] || &usage;
   72: 
   73:     } elsif ( m/^-timeout$/i ){
   74:         $timeout = $ARGV[++$i] || &usage;
   75:         &usage unless ( $timeout =~ m/^\d+$/ );
   76: 
   77:     } elsif ( m/^-passive$/i ){
   78:         $passive = 1;
   79: 
   80:     } elsif ( m/^-v$/i ){
   81:         $verbose = 1;
   82: 
   83:     } else {
   84:         &usage;
   85:     }
   86: }
@ARGV を先頭から順に見ていきます。

ftp://... という引数が指定されていたら、 anonymous FTP とみなします。 ユーザ名として anonymous、パスワードとしてメールアドレスを使うのですが、 このとき whoami コマンド・hostname コマンドを使います。 hostname コマンドの実行結果が FQDN である保証はなく、あくまで 「メールアドレスらしきもの」を取得することしかできません。

引数に GET か PUT が指定されると、その次に続く文字列を取得します。 後に数値やファイル名などを受けるオプション (-password や -in など) は

   64:     } elsif ( m/^-password$/i ){
   65:         $password = $ARGV[++$i] || &usage;
   66: 
という書き方をしています。もし、-password の後に続く引数がなければ $ARGV[++$i] が偽になり、`||' の後に書いてある &usage が実行されます。
&usage は使用方法を表示して終了するだけの関数です。

もし、-password オプションでパスワードが指定されなかったら、 標準入力からパスワードを取得します。
   95: while ( $password eq "" ){
   96:     print "Password: ";
   97:     system("stty -echo >/dev/null 2>&1");
   98:     $password = <STDIN>;
   99:     chomp $password;
  100:     system("stty echo >/dev/null 2>&1");
  101:     print "\n";
  102: }
パスワード入力時には、stty -echo でエコーバックを OFF にし、 パスワード入力が終わると stty echo で元に戻します。
本来、stty を実行するときは、stat(STDIN) で、mode が 端末かどうか (キャラクタデバイスかどうか) を 調べなければいけないのですが、その処理は省略しました。そのため、
% cat password_file | ftptrans ...
とすると stty がエラーを出力してしまうので、 stty >/dev/null 2>&1 として、全ての出力 (標準出力と標準エラー出力) を捨てています。

  120: if ( $timeout ){
  121:     $SIG{ALRM} = sub {
  122:         if ( defined $pid ){
  123:             kill 'TERM', $pid;
  124:         }
  125:         die "TIMEOUT! ($timeout secs)";
  126:     };
  127:     alarm($timeout);
  128: }
もし $timeout が真なら (コマンドラインで -timeout オプションが 指定されたなら)、シグナルの設定をします。

UNIX にはシグナルという仕組みがあり、任意のプロセスに シグナルを送ることで、非同期の動作をさせることができます。 alarm はそのシグナルを利用した仕組みで、例えば alarm(10) とすると、そのときから 10 秒後に 自分自身に SIGALRM というシグナルが飛んできます。

シグナルが飛んできたときに任意の関数を実行するように設定することができます。 C では signal(3) を使いますが、perl では %SIG ハッシュを使います。

$SIG{ALRM} = \&function_name;
とすると、SIGALRM が飛んできたら 関数 function_name が実行されます。 シグナルが飛んできたときに実行される関数をシグナルハンドラといいます。

このプログラムでは、シグナルハンドラに名前のない関数を使っています。 基本的な仕事は die で終了するだけなのですが、この後 fork で 子プロセスを生成しますので、子プロセスのプロセス番号を入れておく $pid が 定義されていたら、子プロセスに SIGTERM を送って終了させます。


  133: $port = getservbyname('ftp', 'tcp');
  134: &client_work(COMMAND, $hostname, $port);
  135: &read_response;
FTP サーバに接続します。このソケット COMMAND は、 以後コマンドコネクション用として使われます。 接続すると、FTP サーバからレスポンスが返ってくるはずなので、 それを読みます。
  137: &send_command("USER $username\r\n");
  138: &read_response;
  139: 
  140: &send_command("PASS $password\r\n");
  141: $ret = &read_response;
  142: 
  143: if ( $ret =~ m/^5\d\d/ ){
  144:     print STDERR "ログインできませんでした。\n";
  145:     exit;
  146: }
  147: 
  148: &send_command("TYPE I\r\n");
  149: &read_response;
ユーザ認証を行います。 まず USER コマンドを送信し、レスポンスを受け取ります。 次に PASS コマンドを送信し、レスポンスを受け取ります。 PASS コマンドのレスポンスコードが 5xx なら、 ログイン失敗ということなので、終了します。

ログインに成功したら、「TYPE I」を送信し、 バイナリモードにします。 これもレスポンスを受け取ります。


  154: if ( ! $passive ){              # Active モード
  155:     ($local_ip, $local_port) = &server_work;
  156: 
  157:                                 # コマンドコネクションに PORT コマンドを送信
  158:     &send_command(
  159:                   sprintf("PORT $local_ip,%d,%d\r\n", $local_port/256, $local_port%256)
  160:                  );
  161:     &read_response;
  162: 
  163: } else {                        # Passive モード
  164:     &send_command("PASV\r\n");
  165:     $ret = &read_response;
  166: 
  167:                                 # レスポンスから情報を取得
  168:     if ( $ret =~ m/^2\d\d .*\((\d+,\d+,\d+,\d+),(\d+),(\d+)\)/ ){
  169:         $data_connection_port = $2*256+$3;
  170:         $data_connection_host = $1;
  171:         $data_connection_host =~ s/,/\./g;
  172: 
  173:     } else {
  174:         print STDERR $ret;
  175:         exit;
  176:     }
  177: }
データコネクションを確立します。

Active モードなら (-passive オプションが指定されていなければ)…

&server_work を呼びます。 &server_work の中では socket・bind・listen が実行され、 ローカルホストの IP アドレスとポート番号が返されます。 そして PORT コマンドを送信し、レスポンスを受け取ります。

Passive モードなら (-passive オプションが指定されていたら)…

PASV コマンドを送信し、レスポンスを受け取ります。 レスポンスは 「227 Entering Passive Mode (192,168,1,1,156,64)」 という形式ですので、
$data_connection_port = 40000 (156*256+64)
$data_connection_host = "192.168.1.1"
というふうに変数に代入します。

  185: if ( $mode eq 'put' ){
  186:     &send_command("STOR $target_file\r\n");
  187: } elsif ( $target_file =~ m|/$| ){
  188:     &send_command("LIST $target_file\r\n");
  189: } else {
  190:     &send_command("RETR $target_file\r\n");
  191: }
PUT なら STOR コマンドを、GET なら RETR コマンドを送信します。 ただし、GET でファイル名を「user@host:dir/」などと 最後を `/' とした場合は、ファイル一覧を取得するため LIST コマンドを送信します。

ここでは、まだ FTP サーバからのレスポンスは受け取りません。 例えば存在するファイルを取得しようとして「RETR filename」とすると、

  1. コマンドコネクションで「RETR filename」を送信
  2. FTP クライアントと FTP サーバの間でデータコネクションを確立
  3. FTP サーバがデータコネクションでファイル内容を送信し始めると同時に、 コマンドコネクションに 「150 Opening BINARY mode data connection for 'filename' (12345 bytes).」 というレスポンスを返す
  4. データコネクションでファイル内容を受け渡す
  5. ファイル転送が終了すると、データコネクションは終了。 コマンドコネクションで「226 Transfer complete.」というレスポンスを返す。
という流れになります (レスポンスが2度返ってくることに注意)。

しかし、存在しないファイルを RETR しようとすると、

  1. コマンドコネクションで「RETR bad_filename」を送信
  2. FTP サーバは「550 bad_filename: No such file or directory.」という レスポンスを返す
となり、データコネクションの生成は行われません。

つまり、ファイルが存在するかどうかがわからないため、 RETR コマンドで送信した後、次のアクションは

  • データコネクションの確立なのか
  • コマンドコネクションからのエラーレスポンスなのか
のどちらなのかが わからないのです。

そのため、

  • ファイルを受け取ろうとしてデータコネクションを accept しようとすると、 ファイルが存在しなかった場合のエラーが読めない。
  • エラーが起こった場合に備えてコマンドコネクションのレスポンスを読むと、 データコネクションが確立していないので、ファイル転送が始まらない
となり、先に進めなくなります。

そこで fork して2つのプロセスが 役割を分担するか、4引数 select を使う必要があります。 このプログラムでは fork を使っています。


  196:                                 # 親プロセス
  197: if ( $pid = fork() ){
  198:     $ret = &read_response;
  199:                                 # エラー。子プロセスを殺す
  200:     if ( $ret =~ m/^5/ ){
  201:         print STDERR $ret;
  202:         kill 'TERM', $pid;
  203:     } else {
  204:         &read_response;
  205:     }
  206:                                 # 子プロセス。データコネクションから読み込む
  207: } else {
  208:     if ( $passive ){
  209:         &client_work(DATA, $data_connection_host, $data_connection_port);
  210:     } else {
  211:         accept(DATA, DATA_WAITING);
  212:     }
  213: 
  214:     if ( $mode eq 'put' ){
  215:         open(IN, $infile) || die "$infile: $!";
  216:         while (<IN>){
  217:             print DATA $_;
  218:         }
  219:         close(IN);
  220:     } else {
  221:         open(OUT, ">$outfile") || die "$outfile: $!";
  222:         print OUT <DATA>;
  223:         close(OUT);
  224:     }
  225:     close(DATA);
  226:     exit;
  227: }
fork して子プロセスを生成します。 親プロセスはコマンドコネクション担当で、 FTP サーバからのレスポンスを受け取ります。 子プロセスはデータコネクション担当で、
  • Passive モードなら、PASV コマンドのレスポンスで指定された IP アドレス・ポート番号に接続します。
  • Active モードなら accept して、相手側から接続してくるのを待ちます。
RETR・STOR・LIST でエラーが発生せず、正常に動作するときは、
  1. 子プロセスが &client_work か accept でデータコネクションを確立
  2. 親プロセスが &read_response でレスポンス (150 Opening BINARY mode data connection) を受け取る
  3. 子プロセスがデータコネクションでファイル内容を送信 (あるいは受信)
  4. 転送終了。データコネクションクローズ
  5. 親プロセスが送信完了を示すレスポンス (226 Transfer Complete.) を受け取る
となります。一方、ファイルがないなどの理由でエラーが発生したときは
  1. 子プロセスが &client_work か accept でデータコネクションを確立
  2. 親プロセスが &read_response でレスポンスを受け取り、エラー発生を知る。 子プロセスを kill で殺す
となり、エラーが起こっても起こらなくても対応できるわけです。
  214:     if ( $mode eq 'put' ){
  215:         open(IN, $infile) || die "$infile: $!";
  216:         while (<IN>){
  217:             print DATA $_;
  218:         }
  219:         close(IN);
  220:     } else {
  221:         open(OUT, ">$outfile") || die "$outfile: $!";
  222:         print OUT <DATA>;
  223:         close(OUT);
  224:     }
子プロセスが担当するファイル転送の部分も説明しておきます。 $mode の値により、ファイルハンドル DATA に書き込むのか (送信)、 読み込むのか (受信) を決めます。 $infile と $outfile の初期値は
   19: $infile    = '-';               # デフォルトは '-' ('-'  は標準入力)
   20: $outfile   = '-';               # デフォルトは '-' ('>-' は標準出力)
となっています。perl ではファイル名として `-' を指定すると標準入出力を 表しますので、-in オプション、-out オプションで指定しなかった場合は、
  • PUT なら、標準入力からデータを読み込み、FTP サーバに送信
  • GET なら、FTP サーバから受信し、標準出力に書き出す
という動作をすることになります。
前へ << FTP クライアントを作ってみよう (4) モジュールを使ってみよう (1) >> 次へ

ご意見・ご指摘は Twitter: @68user までお願いします。