LINEのおうむ返しbotを作ってみた
※本記事はLINE DC Advent Calendar 2021 8日目の記事になります。
はじめに
こんにちは!会津大学コンピュータ理工学部1年のしんぶんぶんです!
今回は初心に戻っておうむ返しbotを作ってみました!
概要
せっかく大学の授業でC言語を学んだので、ソケット通信を実装しておうむ返しbotを作ってみました。
大学ではなぜかC99以前の書き方で教えるというよくわからない授業をしていたので、C言語自体の書き方も色々調べながら実装しました(なので、大学の教え方ではカウンタ変数もすべて関数の最初に定義させます。やばいですね。)。
サーバはLightsailを利用し、証明書はLet's Encryptで取得しました。
ソースコード
リポジトリはこちらです。
作り方
- Messaging APIのチャネルを作成します
- サーバを用意します
- OpenSSLをインストールします(インストール方法はOpenSSLのインストール方法)
- こちらのリポジトリからサーバにコードをクローンします
- Let's Encryptなどでサーバ証明書を発行します
- リポジトリのディレクトリに移動します
-
cert
ディレクトリに、サーバ証明書をfullchain1.pem
、秘密鍵をkey.pem
という名前で保存します -
export TOKEN=YOUR_ACCESS_TOKEN
,export SECRET=YOUR_CHANNEL_SECRET
を実行して環境変数にTOKENとSECRETをセットします -
make build
を実行します -
make run
を実行します - 8765ポートを解放します
- Messaging APIのWebhookURLに、
https://ほすとめいとぱす/:8765
を設定します - おうむ返しされたら成功です!
OpenSSLのインストール方法
※以下はUbuntu環境での導入方法です。詳しくは公式ドキュメントをご参照ください。
- OpenSSLのリポジトリをClone
- リポジトリに移動
- 以下のコマンドを実行してbuild & install
./Configure
make
make install
make test
挙動について
テキストメッセージが送られてくると以下のように出力されます。
-------------waiting for client...-------------
Connection: 147.92.149.165:52244
【Webhook】
POST / HTTP/1.1
host: example.com:8765
x-line-signature: Dyz+ozt9LgrEITzDs8owiig0vfip7rm8LYRYRYOOE2U=
content-type: application/json; charset=utf-8
content-length: 326
user-agent: LineBotWebhook/2.0
{"destination":"Ucb7729a3788376675ee35bdb7228cbe5","events":[{"type":"message","message":{"type":"text","id":"15214071167024","text":"まつもとせんぱーい"},"timestamp":1638965964934,"source":{"type":"user","userId":"U6b53f4ad79a23f5427119cb44f08dbd7"},"replyToken":"32245da8787e4ea0afea8784a2ad772d","mode":"active"}]}
【HMAC verify status】
HMAC is valid
HMAC: Dyz+ozt9LgrEITzDs8owiig0vfip7rm8LYRYRYOOE2U=
【Request Start】
Host: api.line.me
【Certificate】
depth: 2
Subject: GlobalSign
Issuer: GlobalSign
preverify OK
depth: 1
Subject: GlobalSign RSA OV SSL CA 2018
Issuer: GlobalSign
preverify OK
depth: 0
Subject: *.line.me
Issuer: GlobalSign RSA OV SSL CA 2018
preverify OK
【Request】
POST /v2/bot/message/reply HTTP/1.1
Host: api.line.me
Content-Type: application/json
Authorization: Bearer XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
Content-Length: 115
{"replyToken":"32245da8787e4ea0afea8784a2ad772d","messages":[{"type":"text","text":"まつもとせんぱーい"}]}
【Response】
HTTP/1.1 200 OK
Server: openresty
Date: Wed, 08 Dec 2021 12:19:26 GMT
Content-Type: application/json
Content-Length: 2
Connection: keep-alive
cache-control: no-cache, no-store, max-age=0, must-revalidate
expires: 0
pragma: no-cache
x-content-type-options: nosniff
x-frame-options: DENY
x-line-request-id: a16d74ec-e48e-4807-a362-c655e79faec3
x-xss-protection: 1; mode=block
{}
-------------waiting for client...-------------
上から、リクエストが送られてきたサーバのIPアドレスとポート番号、リクエスト内容、署名検証のステータス、replyAPIへのリクエストに関する情報(証明書の検証ステータス、リクエストの内容、レスポンスの内容)になっています。
コード解説
主要な部分だけかいつまんで解説します。
server.c
server.c
はサーバに関する処理を記述しています。
int init_server(struct server_info *my_server_info)
{
// サーバ証明書と秘密鍵のパス
char crt_file[] = "cert/fullchain1.pem";
char key_file[] = "cert/key.pem";
my_server_info->size = sizeof(struct sockaddr_in);
// libcryptoの全ての関数の全てのエラーメッセージをload
SSL_load_error_strings();
// 使用可能なcipherとdigestアルゴリズムを登録
SSL_library_init();
// 全てのcipherとdigestアルゴリズムを内部テーブルに追加
OpenSSL_add_all_algorithms();
// SSL_CTXオブジェクトを生成
// TLSはクライアントが対応している最高のバージョンが設定される
my_server_info->ctx = SSL_CTX_new(TLS_server_method());
// 証明書チェーンをロード
SSL_CTX_use_certificate_chain_file(my_server_info->ctx, crt_file);
// 秘密鍵をロード
SSL_CTX_use_PrivateKey_file(my_server_info->ctx, key_file, SSL_FILETYPE_PEM);
// ソケットを作成
// IPv4, TCP
my_server_info->server = socket(PF_INET, SOCK_STREAM, 0);
// addrのメモリ領域を0バイトで消去する
bzero(&my_server_info->addr, sizeof(my_server_info->addr));
// IPv4
my_server_info->addr.sin_family = AF_INET;
// ローカルインターフェースを指定
// 全てのローカルインターフェイスにバインドされうる
my_server_info->addr.sin_addr.s_addr = INADDR_ANY;
// ポート番号を指定
my_server_info->addr.sin_port = htons(PORT);
// ソケットとアドレス、ポートをバインド
bind(my_server_info->server, (struct sockaddr *)&my_server_info->addr, sizeof(my_server_info->addr));
// 接続の待受を開始
listen(my_server_info->server, 10);
}
init_server
関数ではサーバのイニシャライズを行っています。
void wait_connect(struct server_info *my_server_info)
{
// 接続待機
while (1)
{
printf("\n-------------waiting for client...-------------\n");
// レスポンス用のメモリを確保
char *msg = (char *)malloc(sizeof(char) * LARGE_BUF_SIZE);
// リクエストデータ用のメモリを確保
char *buf = (char *)malloc(sizeof(char) * BODY_SIZE);
char *receive_header = (char *)malloc(sizeof(char) * HEADER_SIZE);
// ソケットの接続を待つ
// addrにはクライアントのIPアドレスとポート番号が入る
// clientにはクライアントのソケットの識別子が入る
int client = accept(my_server_info->server, (struct sockaddr *)&my_server_info->addr, &my_server_info->size);
printf("Connection: %s:%d\n", inet_ntoa(my_server_info->addr.sin_addr), ntohs(my_server_info->addr.sin_port));
// SSL connectionごとに生成される構造体
// ctxの設定を継承する
SSL *ssl = SSL_new(my_server_info->ctx);
// SSL構造体にファイルディスクリプター(ソケット識別子)を設定
SSL_set_fd(ssl, client);
// クライアントがTLS/SSLハンドシェイクを開始するのを待つ
if (SSL_accept(ssl) > 0)
{
receive_SSL_data(ssl, receive_header, buf);
// ヘッダから署名取り出し
char x_line_signature[45];
get_x_line_signature(receive_header, x_line_signature);
// HMAC生成
char sig[45];
create_hmac(buf, sig, sizeof(sig));
// HMAC検証
if (strcmp(sig, x_line_signature) == 0)
{
printf("\n【HMAC verify status】\nHMAC is valid\nHMAC: %s\n", sig);
}
else
{
fprintf(stderr, "\nHMAC is invalid\nx_line_signature: %s[end]\nsig: %s[end]\n", x_line_signature, sig);
continue;
}
reply(buf);
// レスポンスヘッダを作成
char header1[] = "HTTP/1.1 200 OK\nContent-Type: application/json";
// レスポンスを生成
int sres = snprintf(msg, sizeof(msg), "%s\n%s", header1, "Hello!");
// sslにバッファ(msg)を書き込む
SSL_write(ssl, msg, strlen(msg));
// 諸々解放
free(msg);
free(buf);
}
// ソケット識別子を取得
int sd = SSL_get_fd(ssl);
// sslを解放
SSL_free(ssl);
// ソケットを閉じる
close(sd);
}
}
wait_connect
関数ではsocketの接続待機を行っており、リクエストが送られてくるのを待機しています。
リクエストが送られてきたらcreate_hmac
関数にリクエストを渡して署名を生成し、署名検証をしたのち、リクエストをreply
関数に渡して返信処理を行っています。
返信処理が成功した場合はサーバにレスポンスを返却します。
void finish_server(struct server_info *my_server_info)
{
// serverのソケットを閉じる
close(my_server_info->server);
// ctxを解放
SSL_CTX_free(my_server_info->ctx);
}
finish_server
関数ではサーバを終了させる処理を書いています。
hmac.c
hmac.c
では署名を生成する処理を書いています。
void create_hmac(char *data, char *buf, size_t buf_size)
{
u_int reslen;
char res[64 + 1];
char *key = getenv("SECRET");
size_t keylen = strlen(key);
size_t datalen = strlen(data);
if (HMAC(EVP_sha256(), key, keylen, data, datalen, res, &reslen))
{
if (k64_encode(res, reslen, buf, buf_size) == -1)
{
exit(EXIT_FAILURE);
}
}
}
署名生成にはopensslのhmacライブラリを使用しています。
message.c
void reply(char *buf)
{
// replyAPIに渡すbody用のメモリを確保
char *body = (char *)malloc(sizeof(char) * BODY_SIZE);
// メッセージ、リプライトークンを格納するメモリを確保
char *text = (char *)malloc(sizeof(char) * 10001);
char *reply_token = (char *)malloc(sizeof(char) * 33);
// JSONをパースしてtextとreply_tokenを取得
parse(buf, text, reply_token);
create_message_obj(body, text, reply_token);
char *host = "api.line.me";
// 環境変数からLINEのアクセストークンを取得
char *token = getenv("TOKEN");
// reply APIにわたすヘッダを作成
char *header = (char *)malloc(sizeof(char) * HEADER_SIZE);
strcpy(header, "POST /v2/bot/message/reply HTTP/1.1\nHost: api.line.me\nContent-Type: application/json\nAuthorization: Bearer \0");
strcat(header, token);
strcat(header, "\0");
http_request req;
req.header = header;
req.body = body;
req.host = host;
request(req);
free(reply_token);
free(body);
free(text);
free(header);
}
reply
関数では返信処理を行っています。
bufをparse
関数に渡してmessage textとreply tokenを取り出し、create_message_obj
関数に渡して返信するメッセージを作成する処理を書いています。
その後、reply APIに渡すリクエストを生成してrequest
関数に渡しています。
void parse(char *buf, char *text, char *reply_token)
{
// JSONオブジェクトを入れる変数
json_error_t error;
json_t *root;
// JSONファイルを読み込む
root = json_loadb(buf, strlen(buf), 0, &error);
// NULL=読込み失敗
if (root == NULL)
{
printf("[ERR]json load FAILED\n");
return;
}
// eventsの配列サイズ分ループを回す
int events_size = json_array_size(json_object_get(root, "events"));
for (int i = 0; i < events_size; i++)
{
// eventを取得
json_t *event = json_array_get(json_object_get(root, "events"), i);
// event typeを取得
const char *type = json_string_value(json_object_get(event, "type"));
// responseを作成
struct event *response;
// event typeがメッセージだった場合
if (strcmp("message", type) == 0)
{
// messageオブジェクトを取得
json_t *message = json_object_get(event, "message");
// message typeを取得
const char *message_type = json_string_value(json_object_get(message, "type"));
// message typeがtextだった場合
if (strcmp("text", message_type) == 0)
{
// textを取得
strcpy(text, json_string_value(json_object_get(message, "text")));
// printf("%s\n", text);
// reply_tokenを取得
strcpy(reply_token, json_string_value(json_object_get(event, "replyToken")));
}
}
}
return;
}
jsonのパースにはjansson
というライブラリを使用しており、messageのtextを取り出しています。
client.c
client.c
ではhttps clientの処理を書いています。
void request(http_request req)
{
printf("\n【Request Start】\nHost: %s\n", req.host);
int mysocket;
// IPアドレス、ポート番号が入る構造体
struct sockaddr_in server;
// sockaddrをリンクリストで保持する構造体
struct addrinfo hints, *res;
// SSLconnectionごとに生成される構造体
SSL *ssl;
// SSLのコンテキスト構造体
SSL_CTX *ctx;
// 送信するリクエスト
char msg[LARGE_BUF_SIZE];
// IPアドレスの解決
memset(&hints, 0, sizeof(hints));
// IPv4
hints.ai_family = AF_INET;
// TCP
hints.ai_socktype = SOCK_STREAM;
// https
char *service = "https";
int err = 0;
// IPアドレスの解決
if ((err = getaddrinfo(req.host, service, &hints, &res)) != 0)
{
fprintf(stderr, "Fail to resolve ip address - %d\n", err);
return;
}
// ソケットの作成
if ((mysocket = socket(res->ai_family, res->ai_socktype, res->ai_protocol)) < 0)
{
fprintf(stderr, "Fail to create a socket.\n");
return;
}
// TCPのconnectionを貼る
if (connect(mysocket, res->ai_addr, res->ai_addrlen) != 0)
{
printf("Connection error.\n");
return;
}
// libcryptoの全ての関数の全てのエラーメッセージをload
SSL_load_error_strings();
// 使用可能なcipherとdigestアルゴリズムを登録
SSL_library_init();
// SSL_CTXオブジェクトを生成
// TLSはクライアントが対応している最高のバージョンが設定される
ctx = SSL_CTX_new(SSLv23_client_method());
// 証明書読み込み
SSL_CTX_load_verify_locations(ctx, "line_server_cert/rootcacert_r3.pem", NULL);
printf("\n【Certificate】\n");
// サーバ証明書の検証
SSL_CTX_set_verify(ctx, SSL_VERIFY_PEER, verify_callback);
// SSL構造体を生成
ssl = SSL_new(ctx);
// ホスト名検証を行うようにする
enable_hostname_validation(ssl, req.host);
// For SNI
SSL_set_tlsext_host_name(ssl, req.host);
// SSL構造体にファイルディスクリプター(ソケット識別子)を設定
err = SSL_set_fd(ssl, mysocket);
// SSL/TLSハンドシェイクを開始
SSL_connect(ssl);
// headerにContent-Lengthを追加
strcat(req.header, "\nContent-Length: ");
char l[30];
sprintf(l, "%ld", strlen(req.body));
strcat(req.header, l);
strcat(req.header, "\n");
// 送信するリクエストを作成
snprintf(msg, sizeof(msg), "%s\n%s", req.header, req.body);
printf("\n【Request】\n%s\n", msg);
// sslにバッファ(msg)を書き込む
SSL_write(ssl, msg, strlen(msg));
printf("\n【Response】\n");
// データ受信
while (1)
{
/* SSLデータ受信 */
char *temp = (char *)malloc(sizeof(char) * BODY_SIZE);
int sslret = SSL_read(ssl, temp, BODY_SIZE);
printf("%s", temp);
int ssl_eno = SSL_get_error(ssl, sslret);
free(temp);
switch (ssl_eno)
{
case SSL_ERROR_NONE:
break;
case SSL_ERROR_WANT_READ:
case SSL_ERROR_WANT_WRITE:
case SSL_ERROR_SYSCALL:
continue;
}
break;
}
printf("\n");
// TLS/SSLコネクションをシャットダウンする
SSL_shutdown(ssl);
// sslの参照カウントをデクリメント
SSL_free(ssl);
// ctxの参照カウントをデクリメント
SSL_CTX_free(ctx);
// ソケットをクローズ
close(mysocket);
}
request関数ではサーバにリクエストを送信する処理を書いています。
細かい解説は省きますが、SSL_CTX_set_verify
のcallbackをverify_callback
に渡しており、ここでサーバ証明書検証後の処理(証明書内容の出力)を書いています。
また、enable_hostname_validation
関数を呼び出してホスト名の検証も行っています。
苦労した点
メモリまわりで無限にバグる
変数の初期化方法が悪かったせいで、前のリクエストのデータが変数内に残ったままになっているというバグが発生しました。
malloc
を使って動的メモリ確保をし、最後に明示的にfree
で解放するという処理を書きましたが、malloc
は定数倍遅いのでそのうち高速化したいと思っています。
サンプルが少ない
単純にsocket通信を実装しているサンプルはたくさんあるのですが、TLS実装しているサンプルがかなり少なくて大変でした。
数少ないサンプルやOpenSSLの公式ドキュメント、ライブラリのソースコードをよみながらなんとか実装しました。
徹夜だめ
LTのネタにしようと思って1晩で徹夜して作ったので、バグや汚い実装がかなり多かったです。
ある程度バグフィックスやリファクタリングはしたのですが、まだ結構やばいコードが残ってるのでどうにかしたいと思ってます...。
最後に
C++は競プロでちょこっとだけ書くのですが、C言語を授業以外で書いたことがほとんどなかったので、そもそもC言語の仕様を把握するのが大変でした。
ただ、ソケット通信やTLS通信に関する知識が深まったのはとても良い経験になりました。
まだまだ知識不足のせいでリファクタリングできていない部分が多々あるので、今後も修正していきたいと思っています。
参考資料
- https://kaworu.jpn.org/kaworu/2007-03-22-1.php
- https://www.lisz-works.com/entry/jansson-for-wsl
- https://qiita.com/edo_m18/items/41770cba5c166f276a83
- https://www.bit-hive.com/articles/20200407
- https://ken-ohwada.hatenadiary.org/entry/2021/02/27/113436
- https://www.openssl.org/docs/man1.1.1/man3/
- https://github.com/openssl/openssl