はじめに
このエントリは Linux Advent Calendar 2017 の18日分の記事です。
Linux 4.13で カーネル内TLS (KTLS) がマージされました。カーネルでTLSを行なうことのモチベーションは、例えばsendfile
を使ってデータを送信するときに、ユーザランドにデータをコピーすることなく暗号化することなどがあります。詳細は LWNの記事 や netdev 1.2のプレゼンテーション を参照してもらうとして、この記事ではKTLSがマージされるときに一緒にマージされた ULP (Upper Layer Protocol)について扱います。
ULPとは?
ULPとは、ソケットとレイヤ4(現在はTCPのみ)の間にプロトコルを追加するためのフレームワークです。KTLSの場合は、ソケットからTCPにデータを渡す前にTLSの暗号化を行なっています。
カーネルモジュールとして実装可能なので、わりと簡単に簡単にプロトコルを追加することができます。(実装が簡単かどうかはプロトコル次第ですが。)
ユーザプログラムにはほとんど修正は必要なく、追加されたプロトコルを使うかどうかをsetsockopt
を指定するだけです。
KTLSの場合は大体以下のような手順で通信を行ないます(Documentation/networking/tls.txtから抜粋)。
sock = socket(AF_INET, SOCK_STREAM, 0);
// Tell to use ktls
setsockopt(sock, SOL_TCP, TCP_ULP, "tls", sizeof("tls"));
// Set parameters through crypto_info
setsockopt(sock, SOL_TLS, TLS_TX, &crypto_info, sizeof(crypto_info));
const char *msg = "hello world\n";
// Send a message
send(sock, msg, strlen(msg));
// Send a file using sendfile
file = open(filename, O_RDONLY);
fstat(file, &stat);
sendfile(sock, file, &offset, stat.st_size);
1つ目のsetsockopt
で"tls"という名前のプロトコルを使うことを指定して、2つ目のsetsockopt
でTLSに必要なパラメタを設定してます。(crypto_info
の初期化は省略しています。) かなり簡単に使えることがわかると思います。
ULPの初期化
通信の処理は個々のプロトコルに依存して、あまりULPは関係ないため、ULPを使うための初期化のコードだけを見てみます。以下はKTLSのコードの一部です。
static int __init tls_register(void)
{
tls_base_prot = tcp_prot;
tls_base_prot.setsockopt = tls_setsockopt;
tls_base_prot.getsockopt = tls_getsockopt;
tls_sw_prot = tls_base_prot;
tls_sw_prot.sendmsg = tls_sw_sendmsg;
tls_sw_prot.sendpage = tls_sw_sendpage;
tls_sw_prot.close = tls_sk_proto_close;
tcp_register_ulp(&tcp_tls_ulp_ops);
return 0;
}
static void __exit tls_unregister(void)
{
tcp_unregister_ulp(&tcp_tls_ulp_ops);
}
module_init(tls_register);
module_exit(tls_unregister);
モジュールがロードされるとtls_register
が呼ばれます。ここではKTLSで使う関数を用意します。
tls_setsockopt
は先程の例でいう2つ目のsetsockopt
で呼ばれることになります。sendmsg
やsendpage
は名前から推測できる通りソケットにsend
やsendfile
したときに呼ばれる関数を指定しています。
static int tls_init(struct sock *sk)
{
struct inet_connection_sock *icsk = inet_csk(sk);
struct tls_context *ctx;
int rc = 0;
/* allocate tls context */
ctx = kzalloc(sizeof(*ctx), GFP_KERNEL);
if (!ctx) {
rc = -ENOMEM;
goto out;
}
icsk->icsk_ulp_data = ctx;
ctx->setsockopt = sk->sk_prot->setsockopt;
ctx->getsockopt = sk->sk_prot->getsockopt;
sk->sk_prot = &tls_base_prot;
out:
return rc;
}
static struct tcp_ulp_ops tcp_tls_ulp_ops __read_mostly = {
.name = "tls",
.owner = THIS_MODULE,
.init = tls_init,
};
tls_init
は先程の例の1つ目のsetsockopt
で呼ばれます。
キモはsk->sk_prot = &tls_base_prot;
で、ここでレイヤ4の関数(TCPの場合はtcp_sendmsg
などをKTLSのもので置き換えています。これ以降、ソケットで通信を行なうとまずKTLSの関数が呼ばれることになります。
setsockopt
とgetsockopt
を保存しているのは、オリジナルの挙動にフォールバックさせるときに使うためです。
ULPを使ってTCPペイロード透過的圧縮機能(プロトコル)を実装してみる
せっかくなのでULPを使って独自プロトコルを実装してみました。プロトコルといっても複雑なものではなく、単にソケットから渡されたデータを勝手に圧縮してヘッダを付加して送信するだけです。
作成したソースコードはgithubに置いておきました → linux-playground/ulp-comp.c
送信関数
ULPはTCPの送信関数を横取りする形になるので、ソケットから渡される引数を以下のように元の送信関数(tcp_sendmsg)にそのまま渡してやれば、とりあえず(単なるTCPとして)データを送信できます。
int comp_sendmsg(struct sock *sk, struct msghdr *msg, size_t size)
{
struct comp_context *ctx = comp_get_ctx(sk);
return ctx->sendmsg(sk, msg, total);
}
送信バッファ
ユーザのデータはstruct msghdr
の先にあるstruct iovec
のリストになって渡されます。
struct iovec
が持つバッファのまま圧縮はできないため、圧縮後のデータを保存するためのバッファを用意します。データ構造はstruct iovec
のカーネル版struct kvec
です。バッファはヘッダの分を余計に確保します。
struct kvec *comp_clone_iov(struct iov_iter *iter, int *nvecs)
{
struct kvec *kvec;
struct iov_iter _iter;
struct iovec iov;
int i;
kvec = kcalloc(iter->count, sizeof(*kvec), GFP_KERNEL);
i = 0;
iov_for_each(iov, _iter, *iter) {
unsigned long len = iov.iov_len + COMP_HDRSIZE;
kvec[i].iov_base = kmalloc(len, GFP_KERNEL);
kvec[i].iov_len = len;
i++;
}
*nvecs = i;
return kvec;
}
圧縮
データの圧縮にはlib/zlib_deflate/
にあるzlibライブラリを使います。
時間がなかったため、コードはほぼnvram_compress
のコピーになっています。
圧縮元のバッファはiovec
で圧縮先のバッファはkvec
にします。
データフォーマット
圧縮したデータだけを送信しても、受信側でデータ境界がわからなかったり、伸長後のバッファサイズをどれぐらい確保すれば良いかわからない等の問題があるので、圧縮したデータの前に元のデータサイズと圧縮後のデータサイズをつけてTCPに渡します。
データサイズが小さいときに圧縮してもあまり意味がないので、64バイト未満のデータは圧縮せずにそのまま渡します。その場合もヘッダは付けます。
圧縮しつつkvec
を作成する辺りのコードは以下のようになります。圧縮が必要な場合はcomp_compress
を使いデータを圧縮し、そうでなければcopy_from_user
でそのままコピーしています。
i = 0;
iov_for_each(iov, iter, msg->msg_iter) {
unsigned long len = iov.iov_len;
struct comp_hdr *hdr;
hdr = (struct comp_hdr *) kvec[i].iov_base;
hdr->olen = htonl(len);
/* Compress the payload only if it's big enough */
if (len >= COMP_MINIMUM_SIZE) {
int clen;
clen = comp_compress(iov.iov_base, hdr->payload, len, len);
if (clen < 0) {
ret = clen;
goto error;
}
hdr->clen = htonl(clen);
kvec[i].iov_len = clen + COMP_HDRSIZE;
} else {
hdr->clen = htonl(len);
if (copy_from_user(hdr->payload, iov.iov_base, len)) {
ret = -EFAULT;
goto error;
}
kvec[i].iov_len = len + COMP_HDRSIZE;
}
total += kvec[i].iov_len;
i++;
}
送信
先程述べたように、データはstruct msghdr
でTCPに渡されます。ソケットから渡されるmsghdr
を複製しても良いですが、iovec
の部分だけを先程用意したkvec
に置き換えます。
msg->msg_iter.kvec = kvec;
msg->msg_iter.type = ITER_KVEC;
msg->msg_iter.count = total;
この方法が良いかどうかはわからないですが、とりあえず動きます。
受信
送信と同じようにmsghdr
のiovec
をkvec
に置き換えたものをTCPに渡してデータを受信します。
現在の実装では複数のデータがくっついたり分断されているケースは考慮されていません。普通はまずヘッダだけ受信して圧縮後のサイズを把握した後、その分のデータがくるまで何度から受信を繰り返すといった処理が必要になるはずです。今回は時間がなかったため、データが1つだけ完全な形で送られてくるケースのみ対応しています。
安直に上記のような処理を書くのも良いですが、こういうときこそ KCM の出番だと思います。KCMを使うと下位層で指定した形のデータが完成した後、上位層にデータを渡してくれます。そのうちやるかもしれません。
プログラムを試してみる
上記レポジトリをcloneして以下のように実行すると動作を試すことができます。
$ make
$ sudo insmod ulp-comp.ko
$ python3 recv.py
# 別の端末上で実行する
$ python3 send.py 64 # 送りたいデータサイズを指定する
send.py
を読むとわかりますが、実装したプロトコルを使う時は以下のように"comp"というプロトコル名を指定しています。
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM, 0)
s.setsockopt(socket.SOL_TCP, 31, "comp".encode())
おわりに
今回は新しく導入されたULPを紹介し、それを使ってオレオレプロトコルを実装してみました。みなさんも面白プロトコルを実装してみてはいかがでしょうか。