6
Help us understand the problem. What are the problem?

LINEのおうむ返しbotを作ってみた

※本記事はLINE DC Advent Calendar 2021 8日目の記事になります。

はじめに

こんにちは!会津大学コンピュータ理工学部1年のしんぶんぶんです!
今回は初心に戻っておうむ返しbotを作ってみました!

概要

せっかく大学の授業でC言語を学んだので、ソケット通信を実装しておうむ返しbotを作ってみました。
大学ではなぜかC99以前の書き方で教えるというよくわからない授業をしていたので、C言語自体の書き方も色々調べながら実装しました(なので、大学の教え方ではカウンタ変数もすべて関数の最初に定義させます。やばいですね。)。

サーバはLightsailを利用し、証明書はLet's Encryptで取得しました。

ソースコード

リポジトリはこちらです。

作り方

  1. Messaging APIのチャネルを作成します
  2. サーバを用意します
  3. OpenSSLをインストールします(インストール方法はOpenSSLのインストール方法
  4. こちらのリポジトリからサーバにコードをクローンします
  5. Let's Encryptなどでサーバ証明書を発行します
  6. リポジトリのディレクトリに移動します
  7. certディレクトリに、サーバ証明書をfullchain1.pem、秘密鍵をkey.pemという名前で保存します
  8. export TOKEN=YOUR_ACCESS_TOKEN, export SECRET=YOUR_CHANNEL_SECRETを実行して環境変数にTOKENとSECRETをセットします
  9. make buildを実行します
  10. make runを実行します
  11. 8765ポートを解放します
  12. Messaging APIのWebhookURLに、https://ほすとめいとぱす/:8765を設定します
  13. おうむ返しされたら成功です!

OpenSSLのインストール方法

※以下はUbuntu環境での導入方法です。詳しくは公式ドキュメントをご参照ください。

  1. OpenSSLのリポジトリをClone
  2. リポジトリに移動
  3. 以下のコマンドを実行して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晩で徹夜して作ったので、バグや汚い実装がかなり多かったです。
ある程度バグフィックスやリファクタリングはしたのですが、まだ結構やばいコードが残ってるのでどうにかしたいと思ってます...。

LT資料: https://speakerdeck.com/shinbunbun_/linefalseoumufan-sibotwoche-ye-dezuo-routositegirigirijian-nihe-tutatosi-tutarayatuparijian-nihe-tutenakatutahua

最後に

C++は競プロでちょこっとだけ書くのですが、C言語を授業以外で書いたことがほとんどなかったので、そもそもC言語の仕様を把握するのが大変でした。
ただ、ソケット通信やTLS通信に関する知識が深まったのはとても良い経験になりました。
まだまだ知識不足のせいでリファクタリングできていない部分が多々あるので、今後も修正していきたいと思っています。

参考資料

Register as a new user and use Qiita more conveniently

  1. You can follow users and tags
  2. you can stock useful information
  3. You can make editorial suggestions for articles
What you can do with signing up
6
Help us understand the problem. What are the problem?