FTPについて調べてみた

FTPはすでにご存知のかたも多いと思います。
自分はFTPについて、「ファイル転送に使われるプロトコルであり、セキュリティが脆弱である」というくらいしか理解していなかったので詳細を把握するために調べました。

FTPとは

FTPとはインターネットの初期の頃から存在するプロトコルで、今でもインターネットでよく使われています。
用途としては、以下に使われます。

  • ウェブページの各種ファイル(HTMLや画像)をクライアントのPCからサーバーへアップロードする
  • ソフトウェアの配布サイトやFTPファイルサーバーからクライアントPCへダウンロードする

FTPを利用するにはユーザー名とパスワードが必要です。
ソフトを配布するための目的で使う匿名でアクセスできるAnonymous(匿名)FTPサーバーもあります。
しかし、形式上ユーザー名とパスワードは必要なので、ユーザー名にanonymousやftpを使います。パスワードは何でもよいですが、慣習としてメールアドレスを入力するようです。

プロトコルの概要

FTPのプロトコルはRFC959に定義してあります。
RFC959が書かれたのが1985年なのでおよそ30年前に作られたプロトコルです。

FTPは大きくわけると、コネクションの確立とデータ転送にわけることができます。
そして、使うポートも2つが用意してあります。

  • 20: データ転送ポート
  • 21: コントロールポート

20番がデータを転送するときに使うポートで、21番は認証などの接続に使うためのポートです。

コネクションの確立

FTPは アクティブモードパッシブモード のいずれかで動作し、サーバーとの接続をする際にどちらかを選択します。

アクティブモードの場合

アクティブモードの場合、クライアントはデータ転送用のポートを用意しPORTコマンドを使って待ち受けポートをサーバーに伝えます。
サーバーはPORTコマンドを受け取ると、そのポートに対して接続を行います。
しかし、Firewallがある環境の場合、サーバーからの接続が拒否されうまくいかない場合があります。
その際はパッシブモードを使います。

パッシブモード

パッシブモードでは、まず最初にクライアントがPASVというコマンドを使いサーバーに送信します。
そのコマンドを受け取ったサーバーは自身のIPアドレスとポート番号をクライアントに送信します。
クライアントは受け取ったIPアドレスとポート番号へコネクションを確立しにいきます。

アクティブモードはサーバーより接続を行う、パッシブモードはクライアントより接続を行うという違いがあります。
ちなみに、FTPはよく使われるプロトコルのため、PORTコマンドがFirewallを通過する際にPORTコマンドに書かれたPORTは通過できるようにしてくれるFirewallもあるとのことです。

データ転送

接続ができたら、次はデータ転送を行います。
FTPにはデータタイプという考え方があって、現在は2つのモードが使われています。

アスキーモード

アスキーモードは、必要であればデータを変換します。
例えば異なるOS間でファイルを送ると改行コードが違ったりする場合があると思いますが、アスキーモードはファイルを受け取るホストに適した改行コードにファイルを変換します。

Imageモード(バイナリモード)

Imageモードは一般的にバイナリモードと呼ばれます。
バイト単位でデータを転送します。
アスキーモードのようにデータの変換は行われません。

データの転送モードには、以下の3つのモードがあります。

  • ストリームモード
  • ブロックモード
  • 圧縮モード

ストリームモード

データをそのまま転送するモードです。

ブロックモード

データをブロックに分割して転送するモードです。

圧縮モード

ランレングスエンコーディングを使ってデータを圧縮して転送するモードです。

セキュリティ上の問題

FTPはセキュアなプロトコルとして設計されていません。
ユーザー名やパスワードを暗号化せずに送信する問題のほかにも数多くのセキュリティ脆弱性があげられています。

  • 総当たり攻撃
  • en:FTP bounce attack
  • パケットキャプチャ (sniffing)
  • Port stealing
  • en:Spoofing attack
  • ユーザ名保護

また、通信内容を暗号化できないので通信経路上でパケットキャプチャすることで盗聴することができてしまいます。
セキュアにする一般的な方法として、SSL/TLSセッション上で通信を行うようにします。
これをFTPSと言います。
また、SSHを介してファイル転送を行うSFTP、SCPを使います。

ちなみにFTPSとSFTPの違いは以下のようになります。

  • FTPS : FTPの通信をSSL/TLSで暗号化 → FTPの拡張
  • SFTP : SSHの通信を使って、FTPを行う → SSHで動作するアプリケーション

理解を深めるためにtelnetを使って実際にFTPサーバーと通信してみます。

実際にFTPを試してみる

実際にFTPを試してみる場合、FTPサーバーが必要です。
ロリポップ!レンタルサーバーを使えば無料でFTPサーバーが利用できます。

ちなみにtelnetでファイルの送受信はできません。
というのも、telnetでは1つのポートを使った通信しかサポートしていないからです。
FTPはデータ転送用のポートと制御用のポートの2つを利用するため、telnetでは認証しか行えません。

まずは、telnetで認証をやってみます(localhostにftpサーバーが起動しているという前提です)。

1
2
3
4
5
$ telnet localhost 21
Trying 127.0.0.1...
Connected to localhost.
Escape character is '^]'.
220 FTP server ready.

まず、ユーザ名を入力します。

1
2
USER ftp
331 Password required for example.com

次に、パスワードを入力します。

1
2
PASS 123
230 User example.com logged in.

telnetでは他に何もできませんのでQUITします。

1
2
3
QUIT
221 Goodbye.
Connection closed by foreign host.

FTPでファイルの送受信を行う

FTPでファイルの送受信を行うには、ftpコマンドを使います。
-d オプションはデバッグモードです。

1
ftp -d localhost 21

ユーザー名とパスワードを聞かれるので入力します。

lsでファイルの一覧をみることができます。

1
2
3
4
5
6
7
8
9
10
ftp> ls
---> EPSV
229 Entering Extended Passive Mode (|||65086|)
229 Entering Extended Passive Mode (|||65086|)
---> LIST
150 Opening ASCII mode data connection for file list
drwx---r-x 6 shoyan shoyan 4096 Jun 17 11:31 .
drwx---r-x 6 shoyan shoyan 4096 Jun 17 11:31 ..
-rw-r--r-- 1 shoyan shoyan 3096 Jun 17 11:30 index.html
226 Transfer complete

getコマンドでファイルをダウンロードできます。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
ftp> get index.html
local: index.html remote: index.html
---> SIZE index.html
213 3096
---> EPSV
229 Entering Extended Passive Mode (|||65011|)
229 Entering Extended Passive Mode (|||65011|)
---> RETR index.html
150 Opening BINARY mode data connection for index.html (3096 bytes)
100% |************************************************************************************************************************************************************************************************| 3096 4.61 MiB/s 00:00 ETA
226 Transfer complete
3096 bytes received in 00:00 (163.72 KiB/s)
---> MDTM index.html
213 20160617023028
parsed date `20160617023028' as 1466130628, Fri, 17 Jun 2016 11:30:28

参考リンク

Rubyでファイルエンコーディングを確認する

Rubyでファイルエンコーディングを確認するにはNKFモジュールを使って確認します。

http://docs.ruby-lang.org/ja/2.3.0/class/NKF.html

NKFモジュールとは、nkf(Network Kanji code conversion Filter, http://sourceforge.jp/projects/nkf/) をRubyから使うためのモジュールです。

1
2
3
4
5
require 'nkf'

content = File.read("path/to/file.txt")
NKF.guess(content).to_s
=> "Shift_JIS"

このような感じでrspecでテストするときにも使えます。

1
2
3
4
5
6
require 'nkf'

it 'file encoding is Shift_JIS' do
content = File.read("filename.csv")
expect(NKF.guess(content).to_s).to eq 'Shift_JIS'
end

capistrano/wheneverで cannot load such fileがでる

wheneverを導入するためCapfilerequire "whenever/capistrano"と定義して bundle exec cap -T とすると以下のエラーがでた。

1
LoadError: cannot load such file -- /Users/shoyan/app/vendor/bundle/ruby/2.2.0/gems/whenever-0.9.5/lib/whenever/tasks/whenever.rake

実際にvendor/bundle/ruby/2.2.0/gems/whenever-0.9.5/lib/whenever/tasks/whenever.rake を確認してみると、たしかにそのファイルが存在しない。

wheneverのリポジトリを見てみると、シンボリックリンクがはってあった。

bundle install したときにはシンボリックリンクがはられないようだ。

手元のRubyのバージョンは、2.2.4だった。

ruby2.3.1で実行してみると Gem::Package::PathError が発生した。

1
2
ERROR:  While executing gem ... (Gem::Package::PathError)
installing into parent path /Users/shoyan/.rbenv/versions/2.3.0/lib/ruby/gems/2.3.0/gems/capistrano/v3/tasks/whenever.rake of /Users/shoyan/.rbenv/versions/2.3.0/lib/ruby/gems/2.3.0/gems/whenever-0.9.5 is not allowed

このエラーはRuby2.3.2では直っていることが期待される。

回避策

Capfileでの指定はやめて、lib/capistrano/tasks 以下にwhenever.taskを作成した。
これでcapのタスク自体は実行できるようになった。
しかし、ruby2.3.1の環境ではbundle installがこけるのでwheneverの導入自体ができないという状況。

その後

bundle installがこけるバグは以下のPRがでており0.9.6で修正されていた。

また、0.9.5では cap whenever:update_crontabinstance variable @_env not defined が発生してコマンドが正常に実行できないバグがあったが、0.9.7で修正されている。

実はこのバグは自分が踏んでおり、その修正案をPRしてCHANGELOGに名前が刻まれた。

SSHKitを実際に使ってみて理解する

Capistrano/sshkitを紹介します。
https://github.com/capistrano/sshkit

SSHKitはリモートサーバーに対してコマンドを実行するためのツールキットです。
CapistranoやCapistranoプラグインではSSHKitが使われています。

インストール

1
gem install sshkit

コマンドのサンプル

実際に使ってみて理解していきます。
使うには、sshkitをロードする必要があります。

1
2
require 'sshkit'
include SSHKit::DSL

ホスト名を取得する

まずはログインしてホスト名を取得してみましょう。

1
2
3
4
on ['deploy@example.com’] do |host|
puts capture(:hostname)
end
=> example.com

on メソッドに対象のサーバーとブロックを渡します。
対象のサーバーは複数設定することも可能です。
ブロックにはサーバー上で実行するコマンドを設定します。
captureメソッドは渡された引数をコマンドとして実行し、結果をログに出力します。

特定のユーザーでコマンドを実行する

特定のユーザーでコマンドを実行する場合は、asメソッドで指定します。

1
2
3
4
5
on ['example.com'] do |host|
as 'deploy' do
 puts capture(:whoami)
end
end

特定のディレクトリを指定する

特定のディレクトリを指定する場合は、withinメソッドを指定します。

1
2
3
4
5
on ['deploy@example.com'] do |host|
within '/var/log' do
puts capture(:head, '-n5', 'messages')
end
end

/var/loghead -n5 messages を実行します。

環境変数を指定する

withメソッドで環境変数を指定することができます。

1
2
3
4
5
on hosts do |host|
with rack_env: :test do
puts capture("env")
end
end

rack_envに:test を設定しています。

ファイルをチェックして存在すればメッセージを表示、なければファイルを作成する

1
2
3
4
5
6
7
8
9
10
on ['deploy@example.com'] do |host|
f = '/tmp/file'
if test("[ -f #{f} ]")
info "#{f} already exist on #{host}!"
else
execute :touch, f
end
end
INFO [790b6aaa] Running /usr/bin/env touch /tmp/file as deploy@example.com
INFO [790b6aaa] Finished in 0.052 seconds with exit status 0 (successful).

testメソッドでファイルをチェックし、executeメソッドでtouchコマンドを実行しています。

ファイルをアップロードする

ファイルをアップロードすることもできます。

1
2
3
on ['deploy@example.com'] do |host|
upload! 'README.md', '/tmp/README.md'
end

第1引数がローカルのファイルのパス、第2引数がサーバーに配置するファイルのパスです。

また、recursiveオプションをtrueに設定することでディレクトリをアップロードすることもできます。

1
2
3
on hosts do |host|
upload! '.', '/tmp/mypwd', recursive: true
end

ローカルで実行する

ローカルで実行することもできます。

1
2
3
4
5
6
7
8
9
10
11
12
13
run_locally do
within '/tmp' do
execute :whoami
end
end

# もしくは

on(:local) do
within '/tmp' do
execute :whoami
end
end

Rakeタスクで利用する

RakeタスクでSSHKitのDSLを使うこともできます。
この仕組みを利用してCapistranoのプラグインは作成されています。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
require 'sshkit'

SSHKit.config.command_map[:rake] = "./bin/rake"

desc "Deploy the site, pulls from Git, migrate the db and precompile assets, then restart Passenger."
task :deploy do
include SSHKit::DSL

on "example.com" do |host|
within "/opt/sites/example.com" do
execute :git, :pull
execute :bundle, :install, '--deployment'
execute :rake, 'db:migrate'
execute :rake, 'assets:precompile'
execute :touch, 'tmp/restart.txt'
end
end
end

サンプルがこちらにたくさんあるので、参考になると思います。

https://github.com/capistrano/sshkit/blob/master/EXAMPLES.md

base64エンコードのアルゴリズムをRubyで実装する

Base64とは英数字、記号を用いてマルチバイト文字やバイナリデータ(画像など)を扱うためのエンコード方式です。
具体的にはA–Z, a–z, 0–9 までの62文字と、記号2つ (+,/)、さらにパディング(余った部分を詰める)のための記号として = が用いられます。
7ビットの文字コードしか扱うことができない電子メールにおいてよく利用されています。

変換アルゴリズム

変換アルゴリズムは以下となります。

  1. 元データを6ビットずつに分割する(6ビットに満たない部分は0を追加して6ビットにする)。
  2. 各6ビットの値を変換表を使って4文字ずつに変換する(4文字に満たない部分は=記号を使って4文字にする)。

変換例

1. 元データ

文字列: “ABCDEFG”
2進数に変換する: “0100 0001 0100 0010 0100 0011 0100 0100 0100 0101 0100 0110 0100 0111”

rubyでのサンプルコード

1
"ABCDEFG".unpack("B*").pop.scan(/.{1,4}/).join(" ")

2. 6ビットずつに分割

“010000 010100 001001 000011 010001 000100 010101 000110 010001 11”

1
"ABCDEFG".unpack("B*").pop.scan(/.{1,6}/).join(" ")

3. 2ビット余るので、4ビット分0を追加して6ビットにする

“010000 010100 001001 000011 010001 000100 010101 000110 010001 110000”

1
list = "ABCDEFG".unpack("B*").pop.scan(/.{1,6}/).join(" ").split.map { |s| sprintf("%-06s", s).gsub(" ", "0")}.join(" ")

4. 変換表により、4文字ずつ変換

“QUJD”, “REVG”, “Rw”

1
2
3
4
5
6
7
# 変換表を作成する
keys = (0..63).map {|m| sprintf("%06s", m.to_s(2)).gsub(" ", "0")}
values = [('A'..'Z'), ('a'..'z'), ('0'..'9'), ['+', '/']].map { |a| a.to_a }.flatten
base64_table = Hash[[keys, values].transpose]

base64_list = list.map {|a| base64_table[a]}.join.scan(/.{1,4}/)
=> ["QUJD", "REVG", "Rw"]

5. 2文字余るので、2文字分 = 記号を追加して4文字にする

1
base64_list.map {|s| sprintf("%-4s", s).gsub(" ", "=")}

6. Base64文字列

“QUJDREVGRw==”

1
2
base64_str.scan(/.{1,4}/).map {|s| sprintf("%-4s", s).gsub(" ", "=")}.join
=> "QUJDREVGRw=="

簡易的なbase64_decodeメソッド

今までのロジックをメソッドにまとめて簡易的なbase64_decodeメソッドを作成しました。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class Base64
def self.base64_encode(str)
# 変換表を作成する
keys = (0..63).map {|m| sprintf("%06s", m.to_s(2)).gsub(" ", "0")}
values = [('A'..'Z'), ('a'..'z'), ('0'..'9'), ['+', '/']].map { |a| a.to_a }.flatten
base64_table = Hash[[keys, values].transpose]

binary = str.unpack("B*").pop.scan(/.{1,6}/).join(" ").split.map { |s| sprintf("%-06s", s).gsub(" ", "0") }
base64_list = binary.map {|a| base64_table[a]}.join.scan(/.{1,4}/)
base64_list.map {|s| sprintf("%-4s", s).gsub(" ", "=")}.join
end
end

p Base64.base64_encode("ABCDEFG")
=> "QUJDREVGRw=="

RubyのBase64ライブラリでencodeした値と比べてみましょう。

1
2
3
require 'base64'
Base64.encode64("ABCDEFG")
=> "QUJDREVGRw==\n"

Rubyのencode64は最後に改行が入るようですが、encodeされた値は同じですね!

参考リンク