Content-type: message/rfc822 の謎

f:id:ingage:20210419194939p:plain

id:kizashi1122 です。 メールの話です。

メールってとてもややこしいんですよね。 マルチパートと言って、パート部分を複数持つこともできますし、マルチパートを入れ子にすることもできます。

  • メール本体
    • 本文(テキスト)
    • 本文(HTML)
    • 添付ファイル1

みたいなのが典型的なマルチパートメールです。

添付ファイルであれば、Content-Type が image/png だったり application/pdf だったりするわけです。
たまに Content-Type が message/rfc822 の場合もあります。

よくあるのがメールの不達で返ってきたメール(バウンスメール)です。 @ より前のユーザが存在しない場合は、Unknown User で返ってくる例のあのメールです。

メールサーバーによりますがバウンスメールにはそもそも届けたかったメールが添付されていることがあります。 つまりメールにメールが添付されてるということになります。

そのときに使う Content-Typeが message/rfc822 なのです。 こんなイメージです。

Date: Mon, 19 Apr 2021 08:38:03 +0900 (JST)
From: MAILER-DAEMON@example.com (Mail Delivery System)
Subject: Undelivered Mail Returned to Sender
To: system@example.com
MIME-Version: 1.0
Content-Type: multipart/report; report-type=delivery-status;
        boundary="aaaaaa"
Message-Id: <20210418233803@example.com>

This is a MIME-encapsulated message.

--aaaaaa
Content-Description: Notification
Content-Type: text/plain; charset=us-ascii

Unknown User ですよ的なメッセージ



--aaaaaa
Content-Description: Undelivered Message
Content-Type: message/rfc822

Content-Transfer-Encoding: 7bit
Content-Type: text/plain; charset=iso-2022-jp
Mime-Version: 1.0
To: example@example.jp
From: example@example.co.jp
Subject: foo


元メールの本分


--aaaaaa--

みたいな感じです。

タイトルで「謎」と書きましたが、何が謎なのか。

最近こういうバウンスメールをみるのです。

Content-Type: message/rfc822; charset=utf-8
Content-Transfer-Encoding: base64


WyRyO3Ikajh8JC84Zk5pPz0kNz5lJDIkXiQ5ISMbKEINCg0KGyRCQWFCLiRHJE8kNCQ2JCQkXiQ5
JCwhIhsoQg0KGyRCQGhITCEiJSshPCVJMnEwd01NJCskaSROJDRNeE1RSF1HJyROPz1AQSRLSDwk
:
:

ふーん、 元のメッセージが base64 エンコードされてるのね、というメールなのですが、うちのサービスではこのバウンスメールの扱いに失敗します。

なんでだろう?

と思って、RFCのドキュメントを読むことにしました。

RFC 2046: Multipurpose Internet Mail Extensions (MIME) Part Two: Media Types

の Page 29 に

No encoding other than "7bit", "8bit", or "binary" is permitted for the body of a "message/rfc822" entity

とあります。つまり RFC 違反のメールであり、うちが使っているライブラリでは RFC に則って作られていたため、 Content-Transfer-Encoding: base64 が指定してあってもそれを無視して本文をそのまま扱っていたということになります。

フォーマットの違うCSVをインポートしようとしたらエラーで弾けばいいでしょう。 ただメールの世界ではルール違反(RFC違反)はもはや当たり前です。どこまでをサポートすればよいのかは悩み外どころです。

うちでは今回は Content-Transfer-Encoding: base64 が指定してあれば、明示的に base64 でデコードしてあげることにしました。

以上です。

インゲージではエンジニアを募集しています!

ではまた。

OS をアップデートしたら HTTP でつながらなくなったので調べてみた

こんにちは masm11 です。 私は社内向け某サービスを開発・運用しています。

先日、そのステージングで OS をアップデートしたところ、HTTP/HTTPS で つながらなくなってしまいまい、原因を調べました。今日はその件について 書きたいと思います。

やったこと

OS は Amazon Linux 2 です。

sudo yum update

して、EC2 ダッシュボードから再起動し、アプリをデプロイしただけです。

ブラウザから動作確認したところ、つながらなくなっていました…

調べる

とりあえず curl で試してみます。

まずはサーバの外からグローバル IP アドレスで接続してみます。

$ curl http://global-ip-address/
curl: (52) Empty reply from server
$ curl https://global-ip-address/
curl: (7) Failed to connect to global-ip-address port 443: Connection refused

次にサーバ内でプライベート IP アドレスで接続してみたところ、 こちらは正常でした。つまり、nginx で受けて unicorn への流れは問題なさそうです。

問題は、そこより外側、つまり AWS の NAT とか、そのへんにありそうだと感じます。 security group も確認しましたが、ssh だけの制限で、HTTP/HTTPS は制限していませんでした。

トラフィックの様子を見てみましょうか。

sudo tcpdump -n -vv -i eth0 port 80

この状態で接続してみましたが、よく解りません。やっぱり tcpdump の出力は難解です… wireshark にしましょう。

一旦キャプチャします。

sudo tcpdump -n -s 0 -w cap.dat -i eth0

念のため、port 80 は外しました。デフォルトでは大きいパケットは途中で切り捨てられるので、それを防ぐために -s 0 を付けています。-w cap.dat で cap.dat に保存します。

この状態でアクセスした後、ctrl+c で止めます。cap.dat を手元の PC にコピーした後、 wireshark で開きます。

開いた画面は以下です。

f:id:masm11:20210403184718p:plain

SYN パケットの直後に Destination unreachable (Host administratively prohibited) という ICMP パケットが 飛んでますね。このせいでしょう。

これでぐぐったところ、ファイアウォール等が原因のようです。

fail2ban を導入してます。それが誤動作してるのでしょうか? 状況を確認します。

[ec2-user@ip-172-31-9-10 ~]$ sudo iptables -L -v -n
Chain INPUT (policy ACCEPT 0 packets, 0 bytes)
 pkts bytes target     prot opt in     out     source               destination
   44  3080 ACCEPT     all  --  *      *       0.0.0.0/0            0.0.0.0/0            ctstate RELATED,ESTABLISHED
    0     0 ACCEPT     all  --  lo     *       0.0.0.0/0            0.0.0.0/0
    1    88 INPUT_direct  all  --  *      *       0.0.0.0/0            0.0.0.0/0
    1    88 INPUT_ZONES_SOURCE  all  --  *      *       0.0.0.0/0            0.0.0.0/0
    1    88 INPUT_ZONES  all  --  *      *       0.0.0.0/0            0.0.0.0/0
    0     0 DROP       all  --  *      *       0.0.0.0/0            0.0.0.0/0            ctstate INVALID
    0     0 REJECT     all  --  *      *       0.0.0.0/0            0.0.0.0/0            reject-with icmp-host-prohibited
(以下略)

最後が icmp-host-prohibited で REJECT になってます。すごく怪しいです。 そして INPUT_direct やら INPUT_ZONES_SOURCE やら、見慣れない chain があります。 この chain 名でぐぐったところ、firewalld が作るもののようです。

[ec2-user@ip-172-31-9-10 ~]$ sudo systemctl list-unit-files | grep firewall
firewalld.service                             enabled

enable になってる… 止めます。

sudo systemctl stop firewalld

sudo iptables -L -v -n すると、これらの chain がなくなっていました。

curl やブラウザからアクセスして、問題ないことが確認できました。

まとめ

OS のアップデートしただけだったのに、大きく挙動が変わってしまい、困りました。 yum update ではそんなことにならないと思ってたのですが。

そもそも、

[ec2-user@ip-172-31-9-10 ~]$ sudo systemctl status firewalld
● firewalld.service - firewalld - dynamic firewall daemon
   Loaded: loaded (/usr/lib/systemd/system/firewalld.service; disabled; vendor preset: enabled)
   Active: inactive (dead) since 土 2021-04-03 18:34:39 JST; 2s ago
     Docs: man:firewalld(1)
  Process: 24320 ExecStart=/usr/sbin/firewalld --nofork --nopid $FIREWALLD_ARGS (code=exited, status=0/SUCCESS)
 Main PID: 24320 (code=exited, status=0/SUCCESS)

見えますか? vendor preset: enabled になってますね。だからインストールされた時にすぐ enable になったのでしょう。 余計なことをしてくれます…

ちなみに、fail2ban も停止してしまっていました。 こういったツールが2つ以上同時に動くのは危険なので、仕方ないのは解りますが、ほんと余計なことしてくれる…

インゲージではエンジニアを募集しています。 こういうことが (Google 等活用しながら) 自分で解決できる方、ぜひうちで働きませんか? 詳細は以下のページへ!

https://ingage.co.jp/recruit/

Linux デスクトップにおけるクリップボードの性質

f:id:masm11:20210319205809p:plain

こんにちは、masm11 です。

今回はかなり毛色を変えて、デスクトップのある身近な機能について プログラム的な話をしようと思います。

その機能とは、クリップボードです。

セレクションとは

Linux デスクトップにおいて、「クリップボード」と、それと似たものがあるのは、 割と有名なことだと思っています。

以下の 3つがあって、まとめて「セレクション」と呼びます。

  • PRIMARY
  • SECONDARY
  • CLIPBOARD

ユーザの観点から使い方の違いを説明します。

CLIPBOARD はそのままクリップボードとして使われます。 ctrl+c でコピーして ctrl+v でペーストするやつです。

一方 PRIMARY は、範囲をマウスで選択すると、それだけでその範囲がコピーされます。 そして3ボタンマウスの中ボタンでペーストすることができます。

では SECONDARY とは何でしょうか? これは実は特に用途が決まっていません。

プログラム的には

セレクションは3つあり、ユーザから見た違いを説明しました。 では、プログラム的に違いはあるのでしょうか。

実はプログラム的には 3つは全く同じように使えます。

  1. コピー時に、アプリAが「PRIMARY を所有します」と宣言する。
  2. 他のアプリBが PRIMARY を使ってペーストしたい時は、B がデスクトップ経由で A を特定する
  3. B が A に接続する
  4. A と B との間でデータ型をネゴる
  5. A から B へデータを転送する

こんな感じです。PRIMARY でなく CLIPBOARD を使いたい時は CLIPBOARD に置き換えるだけです。

ただし、PRIMARY と CLIPBOARD とではユーザの使い方が違いますので、 そこは「所有します」と宣言するタイミングを変える必要はあります。

インターネット上の記事によっては「PRIMARY と CLIPBOARD はプログラム的な扱いも異なる」と 書いてあることがありますが、3つとも使い方は同じで、ユーザへの見せ方が違うだけです。

データ転送方法

Linux デスクトップのセレクションが、macOS や Windows のクリップボードと内部的に大きく 異なるのは、データの転送方法だと思います。

先程説明したとおり、アプリ同士が直接接続を張って、直接やりとりしています。

デスクトップは何をやっているかと言うと、セレクションを誰が所有しているかを管理しているだけです。 「PRIMARY を所有します」と宣言すると、PRIMARY を所有するアプリの情報を更新し、 別のアプリから「PRIMARY は誰が所有してるか?」と尋ねられたら、どのアプリが所有しているかを 返答します。

デスクトップはデータそのものは持ちません。 CLIPBOARD という名称ではありますが、クリップボードデータは持っていないのです。

他のデスクトップとの違い

ここで疑問に思われる方もいらっしゃるでしょう。

コピー操作をした後、そのアプリを終了したら、ペーストできるのか?

Linux デスクトップではペーストできません。 接続先アプリが存在しないし、 そもそもアプリ終了時点でそのセレクションを誰も持っていない状態になるからです。 ただし、クリップボードマネージャを使っている場合を除きます。

Linux デスクトップを使ってる方は試してみてください。

ここが Linux デスクトップのクリップボードが macOS や Windows のクリップボードと ユーザ視点で大きく異なる点だと思います。

まとめ

Linux デスクトップにおけるセレクション3つについて、 ユーザ視点での違い、プログラム的な違いを説明し、 さらに他のデスクトップのクリップボードとの大きな違いを説明しました。

本当は最後の段落が書きたかっただけです。

弊社には業務で Linux デスクトップを使っている人がいますが、 きっとそのクリップボードがそういうものだとは気づいていないと思います。

Linux デスクトップをこよなく愛するそこのあなた! 業務で Linux デスクトップを 使える日を夢見ながら一緒にインゲージで働きませんか? 詳細は以下のページへ!

https://ingage.co.jp/recruit

ローカルの AWS のアクセスキーを自動的にローテーションする

f:id:kizashi1122:20210315143238p:plain

id:kizashi1122 です。

最初に発行したAWS のアクセスキーをそのままずっと使ってませんか?

docs.aws.amazon.com

そもそもできるならばキーは発行すべきではありませんが、発行することが必要なシチュエーションもありますよね。 上記記事にも、

  • アクセスキーを定期的に更新します

と、あるようにキーは漏洩したときのリスクを減らすためにも更新したほうがいいです。

f:id:kizashi1122:20210315134357p:plain:w200

こうならないようにしたいですね。

これ、実はアクセスキーを自動的にローテションする仕組みがあるんです。

手順

手順は以下の通りです。 .aws/credentials がすでにある前提です。

  • 当該 IAM ユーザに自身のキーを作成・削除する権限を与える
  • aws-rotate-iam-keys をインストールする
  • 依存コマンドである aws コマンドをインストールする
  • 依存コマンドである jq コマンドをインストールする

1つずつ見ていきましょう。

IAM ユーザの権限追加

以下のポリシーが追加されていればいいです。

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Action": [
                "iam:ListAccessKeys",
                "iam:CreateAccessKey",
                "iam:DeleteAccessKey"
            ],
            "Resource": [
                "arn:aws:iam::*:user/${aws:username}"
            ]
        }
    ]
}

aws-rotate-iam-keys インストール

 $ git clone https://github.com/rhyeal/aws-rotate-iam-keys.git
 $ sudo cp aws-rotate-iam-keys/src/bin/aws-rotate-iam-keys /usr/local/bin/
 $ rm -rf aws-rotate-iam-keys

こんな感じで PATH の通ったところにおいてもらえたらいいです。

aws コマンドインストール

 $  curl "https://s3.amazonaws.com/aws-cli/awscli-bundle.zip" -o "awscli-bundle.zip"
 $  unzip awscli-bundle.zip
 $  sudo ./awscli-bundle/install -i /usr/local/aws -b /usr/local/bin/aws

jq コマンドインストール

$ sudo yum install jq

yum でインストールできます(環境によっては他の方法でインストールしてください)

以上!

実行

超簡単です。

$ aws-rotate-iam-keys

だけ。プロファイルを切ってる場合は、

$ aws-rotate-iam-keys -p other_profile

と指定することができます。

あとはこれを crontab に入れるなり、.bashrc に追加するなりすれば自動化できます。

f:id:kizashi1122:20210315141736p:plain:w200

緑が気持ちいいです! インゲージの開発メンバーは皆この仕組みを導入しています。

実は・・・

今回の話は、2020年2月の JAWSUG で ABEJA の村主さんが発表されたこちらのスライドに書いている内容そのまんまです。

speakerdeck.com

他にもお役立ち情報が沢山載っているので必見です。

インゲージではエンジニアを募集しています! 興味ある方は こちら まで!

ActiveJob で queue を動的に指定する3つの方法

f:id:shutooike:20210311110057p:plain おはようございます。 @shutooike です!

業務上であるジョブのキューを条件によって動的に変えたい場面に遭遇し、ActiveJobのソースを読んだのでメモを残しておきます。

前提条件

Rails version: 5.2

queue_adapter: Sidekiq

対象のジョブクラスはこんな感じ

class SampleJob < ApplicationJob
  queue_as 'default'
  
  rescue_from(StandardError) do
    retry_job(wait: 5.minutes)
  end
  
  def perform(args)
    do_something
  end
end

方法1

ActiveJob::Core.setActiveJob::Enqueuing#enqueuequeue を渡す

ジョブの呼び出し元で制御するという思いつく限り一番簡単な方法です。

実装

# set を使うパターン
if ConditionModel.exists?(id: condition.id)
  SampleJob.set(queue: 'another_queue').perform_later(args)
else
  SampleJob.perform_later(args)
end

# enqueue に渡すパターン
if ConditionModel.exists?(id: condition.id)
  SampleJob.new(args).enqueue(queue: 'another_queue')
else
  SampleJob.perform_later(args)
end

内部をチラッと覗くと

module ActiveJob
  module Core
...
    module ClassMethods
...
      def set(options = {})
        ConfiguredJob.new(self, options)
      end
    end
...

ActiveJob::Core.set 内で ActiveJob::ConfiguredJob というインスタンスが作られます。

module ActiveJob
  class ConfiguredJob #:nodoc:
    def initialize(job_class, options = {})
      @options = options
      @job_class = job_class
    end
...
    def perform_later(*args)
      @job_class.new(*args).enqueue @options
    end
  end
end

ActiveJob::ConfiguredJob#perform_later はジョブのインスタンスを作って ActiveJob::Enqueuing#enqueue を呼ぶので、enqueue に渡すパターンと行き着く先は同じです。

#enqueue の実装は以下のようになっており、 options['queue'] があれば ジョブインスタンスの queue_name を上書きします。

module ActiveJob
  module Enqueuing
...    
    def enqueue(options = {})
      self.scheduled_at = options[:wait].seconds.from_now.to_f if options[:wait]
      self.scheduled_at = options[:wait_until].to_f if options[:wait_until]
      self.queue_name   = self.class.queue_name_from_part(options[:queue]) if options[:queue]
      self.priority     = options[:priority].to_i if options[:priority]
      run_callbacks :enqueue do
        if scheduled_at
          self.class.queue_adapter.enqueue_at self, scheduled_at
        else
          self.class.queue_adapter.enqueue self
        end
      end
      self
    end

今回の変更対象のジョブはいろんなところで呼ばれていて、呼び出し元を変更するコストが高かったのでこの方法は 不採用

方法2

before_enqueue#queue_name を動的に変更する

ActiveJob は他の Rails のライブラリと同様コールバックを持っています。

今回はエンキュー前に呼ばれる必要があるので、before_enqueue コールバックを使います。

実装

class SampleJob < ApplicationJob
  queue_as 'default'
  
  rescue_from(StandardError) do
    retry_job(wait: 5.minutes)
  end
  
+  before_enqueue do |job|
+    if ConditionModel.exists?(id: job.arguments[0]) 
+      job.queue_name = 'another_queue'
+    end
+  end

...

これだと変更箇所が一箇所で済むのでいい感じです。

ただ、今回の変更対象のジョブは上の実装のようにジョブの引数を使ってDBにアクセスし queue を変更するか確認する必要がありました。

もしジョブ実行中にDBアクセスに失敗してエラーになった場合、retry_job 時にも before_enqueue が呼ばれてDBアクセスでエラーになり、ジョブのエンキューされないまま失敗してしまう可能性があると指摘をいただきました。なので方法2も 不採用

ただ、方法1を使うのはエンジニアとして負けな気がしたので頑張って探します。

方法3

ActiveJob::Core.queue_as にブロックを渡す

先に実装を示します。

実装

class SampleJob < ApplicationJob
-  queue_as 'default'

+  queue_as do
+    if ConditionModel.exists?(id: self.arguments[0])
+      'another_queue'
+    else
+      'default'
+    end
+  end

...

方法2の before_enqueue とほぼ同じ処理を queue_as のブロックに渡しただけです。

これでうまくいく理由をAJのソースコードと共に順を追って説明していきます。

まず、queue_as の定義を見ます。

module ActiveJob
  module QueueName
    extend ActiveSupport::Concern

    # Includes the ability to override the default queue name and prefix.
    module ClassMethods
...

      def queue_as(part_name = nil, &block)
        if block_given?
          self.queue_name = block
        else
          self.queue_name = queue_name_from_part(part_name)
        end
      end

...

    included do
      class_attribute :queue_name, instance_accessor: false, default: default_queue_name
      class_attribute :queue_name_delimiter, instance_accessor: false, default: "_"
    end

...

ブロックが渡されたらブロックをそのままクラス属性の queue_name に入れ、 それ以外は引数を queue_name_from_part(プレフィックスとかをつける処理)に通してクラス属性の queue_name に入れていますね。

このクラス属性の queue_name はどこで使われるかというと、

module ActiveJob
  module Core
...

    def initialize(*arguments)
      @arguments  = arguments
      @job_id     = SecureRandom.uuid
      @queue_name = self.class.queue_name
      @priority   = self.class.priority
      @executions = 0
    end
...

ジョブのコンストラクタ内で @queue_name に代入されています。

次にジョブが Sidekiq にエンキューされるまでの流れを追います。 まず方法1でも見た ActiveJob::Enqueuing#enqueue をもう一度確認します。

module ActiveJob
  module Enqueuing
...    
    def enqueue(options = {})
      self.scheduled_at = options[:wait].seconds.from_now.to_f if options[:wait]
      self.scheduled_at = options[:wait_until].to_f if options[:wait_until]
      self.queue_name   = self.class.queue_name_from_part(options[:queue]) if options[:queue]
      self.priority     = options[:priority].to_i if options[:priority]
      run_callbacks :enqueue do
        if scheduled_at
          self.class.queue_adapter.enqueue_at self, scheduled_at
        else
          self.class.queue_adapter.enqueue self
        end
      end
      self
    end

最後に queue_adapterenqueue メソッドが呼ばれています。

弊社はアダプターに Sidekiq を使っているので SidekiqAdapter#enqueue が呼ばれます。

module ActiveJob
  module QueueAdapters
...
    class SidekiqAdapter
      def enqueue(job) #:nodoc:
        # Sidekiq::Client does not support symbols as keys
        job.provider_job_id = Sidekiq::Client.push \
          "class"   => JobWrapper,
          "wrapped" => job.class.to_s,
          "queue"   => job.queue_name,
          "args"    => [ job.serialize ]
      end
...

SidekiqAdapter#enqueue はジョブインスタンスを引数に受け、Sidekiq 側に投げる際、 "queue"job.queue_name を指定していることが確認できます。

この job.queue_nameActiveJob::QueueName に定義されています。

module ActiveJob
  module QueueName
...

    # Returns the name of the queue the job will be run on.
    def queue_name
      if @queue_name.is_a?(Proc)
        @queue_name = self.class.queue_name_from_part(instance_exec(&@queue_name))
      end
      @queue_name
    end
  end
end

#queue_name はインスタンス変数 @queue_name がブロックなら、instance_exec*1 を使いジョブインスタンスのコンテキストでブロックを実行した結果を @queue_name に代入したあと返し、ブロック以外なら、@queue_name をそのまま返しています。

まとめると、queue_as にブロックを渡した場合、最終的に Sidekiq に "queue" として渡す値は、初回はブロックの実行結果を、2回目以降は初回の実行結果が返すこともわかりました。これにより retry_job 時(2回目以降)にはDBアクセスしないことがわかったので、ジョブのエンキューされないまま失敗してしまう心配もなく動的に queue を変更できるようになりました。ということで今回は方法3を 採用 🎉

補足

なぜリトライ時(2回目以降)も初回のブロック実行結果が引き継がれるの?

ActiveJob から Sidekiq::Client に投げる際、"args"job.serialize を渡しています。

module ActiveJob
  module QueueAdapters
...
    class SidekiqAdapter
      def enqueue(job) #:nodoc:
        # Sidekiq::Client does not support symbols as keys
        job.provider_job_id = Sidekiq::Client.push \
          "class"   => JobWrapper,
          "wrapped" => job.class.to_s,
          "queue"   => job.queue_name,
          "args"    => [ job.serialize ]
      end
...

ジョブインスタンスの #serializeActiveJob::Core に定義があって

module ActiveJob
  module Core
...

    # Returns a hash with the job data that can safely be passed to the
    # queueing adapter.
    def serialize
      {
        "job_class"  => self.class.name,
        "job_id"     => job_id,
        "provider_job_id" => provider_job_id,
        "queue_name" => queue_name,
        "priority"   => priority,
        "arguments"  => serialize_arguments_if_needed(arguments),
        "executions" => executions,
        "locale"     => I18n.locale.to_s
      }
    end
...

上記のハッシュを返します。このハッシュに queue_name もありますね。

serialize があれば deserialize もあるはずです。deserialize だけを探してもいいですが、せっかくなので Sidekiq から ActiveJob のジョブが動く流れを追います。(Sidekiq のコードは追いません)

module ActiveJob
  module QueueAdapters
...
    class SidekiqAdapter
      def enqueue(job) #:nodoc:
        # Sidekiq::Client does not support symbols as keys
        job.provider_job_id = Sidekiq::Client.push \
          "class"   => JobWrapper,
          "wrapped" => job.class.to_s,
          "queue"   => job.queue_name,
          "args"    => [ job.serialize ]
      end   
...
      class JobWrapper #:nodoc:
        include Sidekiq::Worker

        def perform(job_data)
          Base.execute job_data.merge("provider_job_id" => jid)
        end
      end
...

Sidekiq の worker は Redis からジョブ情報を取得し、 "class"に指定したクラスのインスタンスの perform"args" の一つ目の要素を引数に実行します。

ここで "class" として渡されている JobWrapperperform の処理は

module ActiveJob
  module QueueAdapters
    class SidekiqAdapter
...
      class JobWrapper #:nodoc:
        include Sidekiq::Worker

        def perform(job_data)
          Base.execute job_data.merge("provider_job_id" => jid)
        end
      end
...

ActiveJob::Base.execute を 呼び出しています。

module ActiveJob
  module Execution
...
    module ClassMethods
...
      def execute(job_data) #:nodoc:
        ActiveJob::Callbacks.run_callbacks(:execute) do
          job = deserialize(job_data)
          job.perform_now
        end
      end
    end
...

お、deserialize が出てきました。クラスメソッド の deserialize を探します。

module ActiveJob
  module Core
...
    module ClassMethods
      # Creates a new job instance from a hash created with +serialize+
      def deserialize(job_data)
        job = job_data["job_class"].constantize.new
        job.deserialize(job_data)
        job
      end

serialize で作ったハッシュをもとに新しいジョブのインスタンス作成すると言ってますね。 job_data というハッシュの "job_class" からジョブインスタンスを作成して、インスタンスメソッドの deserialize を呼び出しています。 インスタンスメソッドの deserialize を探します。

module ActiveJob
  module Core
...

    def deserialize(job_data)
      self.job_id               = job_data["job_id"]
      self.provider_job_id      = job_data["provider_job_id"]
      self.queue_name           = job_data["queue_name"]
      self.priority             = job_data["priority"]
      self.serialized_arguments = job_data["arguments"]
      self.executions           = job_data["executions"]
      self.locale               = job_data["locale"] || I18n.locale.to_s
    end
...

やっと辿り着きました。ジョブインスタンスの queue_namejob_data["queue_name"] が代入されています。 job_data["queue_name"] の値はブロックの実行結果が入っているのでリトライ時(2回目以降)に初回のブロック実行結果が引き継がれます。

おわりに

ActiveJob はめちゃくちゃ読みやすかったです!

【宣伝】 弊社では必要とあらばライブラリのソースコードをバリバリ読むぜ!というロックなエンジニアを募集しております!ご興味あればぜひ下記リンクをご覧ください! ingage.co.jp

ではまた!

*1:instance_exec についてはこちらの記事がわかりやすかったです。https://secret-garden.hatenablog.com/entry/2015/10/18/000000

MessageEncryptor の互換性を調べてみた

f:id:masm11:20210307174016p:plain

こんにちは、masm11 です。

Rails に MessageEncryptor というクラスがあります。 何かを暗号化/復号する際に便利ですね。

例えば、

KEY = 'somekeysomekeysomekeysomekeysomekeysomekeysomekey'
enc = ActiveSupport::MessageEncryptor.new(KEY, cipher: 'aes-256-cbc')

としておけば、

data = enc.encrypt_and_sign(obj)
obj = enc.decrypt_and_verify(data)

で暗号化と復号ができます。

ところが、Ruby 2.4 で仕様が変わってしまったんです。 KEY がちょうど 32文字でないといけなくなりました。

ちょっと試したところでは、元のコードだと

key must be 32 bytes

というエラーが出ますし、

KEY = 'somekeysomekeysomekeysomekeysome'

と 32文字で切り捨てたキーを指定すると、

ActiveSupport::MessageVerifier::InvalidSignature

というエラーが出て復号できません。

さぁ困ったどうしよう、ということで調べた結果です。

結論は一番最後 (まとめ) にあります。

サンプルを作る

まずは Ruby 2.3.3 + Rails 5.2.4.4 でサンプルを作ります。

enc = ::ActiveSupport::MessageEncryptor.new('0123456789abcdefghijklmnopqrstuvwxyz', cipher: 'aes-256-cbc')

長い文字列がキーです。

enc.encrypt_and_sign('short string')
=> "MGVaT0NTWFkweTdCWFV5SUJVSkh1WkdMaFRscDVUYllGaHlKc2tzZ2hKQT0tLXFXdGJuRmdsV1VmTUVMMUt2VDUyalE9PQ==--8ae97f3fc117a62513fb8ca242f86d893f601c83"

short string という文字列を暗号化してみました。 その結果、長い文字列が得られています。これが暗号化文字列です。

OpenSSL を直接使って復号してみる

OpenSSL は 1.1.1i を使用しました。

上記の通り、サンプルは -- の左と右に分けられます。 このうち左側を取り出し、base64 デコードします。

$ echo MGVaT0NTWFkweTdCWFV5SUJVSkh1WkdMaFRscDVUYllGaHlKc2tzZ2hKQT0tLXFXdGJuRmdsV1VmTUVMMUt2VDUyalE9PQ== | base64 -d
0eZOCSXY0y7BXUyIBUJHuZGLhTlp5TbYFhyJsksghJA=--qWtbnFglWUfMEL1KvT52jQ==

また base64 っぽい文字列が出てきました。

-- の左側を base64 デコードします。

$ echo 0eZOCSXY0y7BXUyIBUJHuZGLhTlp5TbYFhyJsksghJA= | base64 -d | od -tx1c
0000000  d1  e6  4e  09  25  d8  d3  2e  c1  5d  4c  88  05  42  47  b9
        321 346   N  \t   % 330 323   . 301   ]   L 210 005   B   G 271
0000020  91  8b  85  39  69  e5  36  d8  16  1c  89  b2  4b  20  84  90
        221 213 205   9   i 345   6 330 026 034 211 262   K     204 220
0000040

バイナリなので od に食わせています。 これが暗号化されたデータそのものです。

一方、-- の右側を base64 デコードします。

$ echo qWtbnFglWUfMEL1KvT52jQ== | base64 -d | od -tx1c                    
0000000  a9  6b  5b  9c  58  25  59  47  cc  10  bd  4a  bd  3e  76  8d
        251   k   [ 234   X   %   Y   G 314 020 275   J 275   >   v 215
0000020

こちらもバイナリなので od に食わせています。 これは iv (initialization vector) です。 暗号化の計算に使う初期値です。 擬似乱数のシードみたいなものと考えると良いでしょう。

また、キーの16進表記が必要なので、キーも od に食わせます。

$ echo -n 0123456789abcdefghijklmnopqrstuvwxyz | od -tx1c
0000000  30  31  32  33  34  35  36  37  38  39  61  62  63  64  65  66
          0   1   2   3   4   5   6   7   8   9   a   b   c   d   e   f
0000020  67  68  69  6a  6b  6c  6d  6e  6f  70  71  72  73  74  75  76
          g   h   i   j   k   l   m   n   o   p   q   r   s   t   u   v
0000040  77  78  79  7a
          w   x   y   z
0000044

ここまで準備ができたら、いよいよ openssl コマンドで復号します。

$ echo 0eZOCSXY0y7BXUyIBUJHuZGLhTlp5TbYFhyJsksghJA= | base64 -d | openssl enc -d -K 303132333435363738396162636465666768696a6b6c6d6e6f70717273747576 -iv a96b5b9c58255947cc10bd4abd3e768d -aes-256-cbc | od -tx1c
0000000  04  08  49  22  11  73  68  6f  72  74  20  73  74  72  69  6e
        004  \b   I   " 021   s   h   o   r   t       s   t   r   i   n
0000020  67  06  3a  06  45  54
          g 006   : 006   E   T
0000026

echo 0eZOCSXY0y7BXUyIBUJHuZGLhTlp5TbYFhyJsksghJA= | base64 -d の部分は先程見ました。 -- の左側を base64 デコードした、暗号化されたデータそのものです。これを openssl の 標準入力に与えています。

openssl enc -d は復号指示です。

-K 303132333435363738396162636465666768696a6b6c6d6e6f70717273747576 はキーの16進表記です。 先程 od でキーの16進表記を調べたので、それを指定します。ただし、キーの32文字までです。

-iv a96b5b9c58255947cc10bd4abd3e768d は iv の16進表記です。 こちらも先程 od で16進表記にしていますので、それを指定します。

-aes-256-cbc はアルゴリズムですね。

その結果を od に食わせています。よく見ると、それらしい文字が見えますね。復号できていそうです。

では、このバイナリから Ruby のオブジェクトを復元してみます。

使用した Ruby は 2.7.2 です。

Marshal.load("\x04\x08\x49\x22\x11\x73\x68\x6f\x72\x74\x20\x73\x74\x72\x69\x6e\x67\x06\x3a\x06\x45\x54")
=> "short string"

復元できました!

署名を検証する

最初に得たサンプル暗号化文字列のうち、-- より右側は署名です。 これを検証してみます。

OpenSSL::HMAC.hexdigest(OpenSSL::Digest::SHA1.new, '0123456789abcdefghijklmnopqrstuvwxyz', 'MGVaT0NTWFkweTdCWFV5SUJVSkh1WkdMaFRscDVUYllGaHlKc2tzZ2hKQT0tLXFXdGJuRmdsV1VmTUVMMUt2VDUyalE9PQ==')
=> "8ae97f3fc117a62513fb8ca242f86d893f601c83"

OpenSSL::HMAC.hexdigest を呼び出しています。

  • SHA1 は固定です。
  • '0123456789abcdefghijklmnopqrstuvwxyz' はキーです。ここではキー全体を指定しています。
  • 'MGVaT0NTWFkweTdCWFV5SUJVSkh1WkdMaFRscDVUYllGaHlKc2tzZ2hKQT0tLXFXdGJuRmdsV1VmTUVMMUt2VDUyalE9PQ==' は暗号化文字列の -- より左です。

このように計算することで、"8ae97f3fc117a62513fb8ca242f86d893f601c83" が得られました。 これはサンプルの暗号化文字列の -- より右側に一致します。

署名が検証できました。

MessageEncryptor の構造

結局、MessageEncryptor はどのような処理をしているのでしょうか? まとめてみました。

  1. 暗号化
    1. シリアライズする (A)
    2. iv を決める (B)
    3. B とキー32文字を使って A を暗号化する (C)
    4. B, C をそれぞれ base64 エンコードする (D, E)
    5. E と D を -- を挟んでつなげる (F)
  2. 署名
    • キー全体を使って F の HMAC を計算する (G)
  3. F と G を -- を挟んでつなげる

上の復号手順は、これを逆にたどったものです。

MessageEncryptor の互換性

ここまで MessageEncryptor のコードを読みながら調べたのですが、 結局、キー32文字とキー全体の両方が必要そうです。 ここに復号できない原因があるわけです。

と、ここまで来て、ふと気づきました。

MessageEncryptor のコンストラクタは以下のようになっています。

def initialize(secret, *signature_key_or_options)
  options = signature_key_or_options.extract_options!
  sign_secret = signature_key_or_options.first
  @secret = secret
  @sign_secret = sign_secret          # <--------------- !!!
  @cipher = options[:cipher] || self.class.default_cipher
  @digest = options[:digest] || "SHA1" unless aead_mode?
  @verifier = resolve_verifier
  @serializer = options[:serializer] || Marshal
end

なんと、署名用のキーが指定できるではないですか。

enc = ::ActiveSupport::MessageEncryptor.new('0123456789abcdefghijklmnopqrstuv', '0123456789abcdefghijklmnopqrstuvwxyz', cipher: 'aes-256-cbc')
enc.decrypt_and_verify('MGVaT0NTWFkweTdCWFV5SUJVSkh1WkdMaFRscDVUYllGaHlKc2tzZ2hKQT0tLXFXdGJuRmdsV1VmTUVMMUt2VDUyalE9PQ==--8ae97f3fc117a62513fb8ca242f86d893f601c83')
=> "short string"

復号できました!

まとめ

Rails がそう簡単に互換性を捨てるはずがありませんね。解ってしまえば簡単なことでした。

enc = ActiveSupport::MessageEncryptor.new(KEY[0..31], KEY, cipher: 'aes-256-cbc')

こうですね。

インゲージではエンジニアを募集しています。 こんなことに熱中できる方、うちで働きませんか? 詳細は以下のページへ!

https://ingage.co.jp/recruit

ではまた!

MITM プロキシを作る

f:id:masm11:20210221003205p:plain

こんにちは、masm11 です。

MITM (man-in-the-middle) プロキシをご存知でしょうか? ブラウザとサーバの間に入って HTTPS の仲介をするのは普通の プロキシと同じですが、通信内容を覗くことができます。 もちろん自由に覗けてしまっては HTTPS の意味がありませんので、 特別な証明書をブラウザにインストールする必要はあります。

今回はそんな MITM プロキシを Ruby で作ってみたいと思います。

先に成果物

というか、既に作って、以下に置いてあります。

https://github.com/masm11/mitm-proxy2/blob/master/mitm-proxy.rb

今回はこれがどのように動いているのか、説明してみたいと思います。

CA 証明書の作成

https://github.com/masm11/mitm-proxy2/blob/master/mitm-proxy.rb#L15-L46

このメソッドは Ruby の OpenSSL を使って、CA のオレオレ証明書と 秘密鍵を生成しています。

  # CA の情報を設定
  name = OpenSSL::X509::Name.new
  name.add_entry 'C',  'JP'
  name.add_entry 'ST', 'Osaka'
  name.add_entry 'DC', 'Kita-ku'
  name.add_entry 'O',  'INGAGE Inc.'
  name.add_entry 'CN', 'Masm11 CA'

この辺は、いつも openssl コマンドで CSR を作る時に入力している項目ですね。

  # CA の秘密鍵/公開鍵を生成
  rsa = OpenSSL::PKey::RSA.generate(2048)

RSA 2048bit の秘密鍵/公開鍵を生成しています。

  # CA の秘密鍵を保存
  File.write('ca.pkey', rsa.export(OpenSSL::Cipher::Cipher.new('aes256'), CA_PASSPHRASE))

秘密鍵にパスフレーズを設定してファイルに保存しています。 起動ごとに作る必要はありませんので、作ったものはファイルに保存しておくことにしました。

  # CA 証明書を作成
  cert = OpenSSL::X509::Certificate.new
  cert.not_before = Time.now
  cert.not_after = Time.now + 3600 * 24 * 365

有効期限はとりあえず1年とします。

  cert.public_key = rsa.public_key
  cert.serial = 1
  cert.issuer = name
  cert.subject = name

公開鍵等を設定しています。

  ext = OpenSSL::X509::Extension.new('basicConstraints', OpenSSL::ASN1.Sequence([OpenSSL::ASN1::Boolean(true)]))
  cert.add_extension(ext)

CA の証明書として使えるようにします。

  cert.sign(rsa, sha1)

最後に署名します。

  # CA 証明書を保存
  File.write('ca.crt', cert.to_pem)

こちらも同様に保存しておきます。

CA 証明書の読み込み

https://github.com/masm11/mitm-proxy2/blob/master/mitm-proxy.rb#L48-L51

2回め以降の起動時は、CA 証明書はファイルから読み込むことにします。

read_ca_cert はファイルからオブジェクトを生成して返しているだけです。

サーバ証明書の生成

今度は、CA でなくサーバの証明書の生成です。

CA は本物のサーバの秘密鍵を持っていないので、自前で秘密鍵を用意し、 対応する証明書も用意します。その証明書は上で作った CA の秘密鍵で 署名されているわけです。

先程とよく似ているので、違う部分だけ説明します。

  begin
    return OpenSSL::PKey::RSA.new(File.read("mitm-proxy/#{domain}.pkey")),
           OpenSSL::X509::Certificate.new(File.read("mitm-proxy/#{domain}.crt"))
  rescue Errno::ENOENT
  end

こちらも、一度作ったものはファイルに保存することにしました。

ここでは、ファイルから読んでオブジェクトを生成して返しています。 ただしファイルがなくて ENOENT になった場合は次の処理へ進みます。 なお、他の例外が発生した場合は呼び出し元へそのまま投げます。

 name.add_entry 'CN', domain

サーバ証明書の CommonName はそのドメインです。

  crt.issuer = ca_cert.issuer
  crt.subject = name

発行者は CA です。また対象はそのサーバです。

起動時の初期化

ここからは、処理の順を追って説明します。

https://github.com/masm11/mitm-proxy2/blob/master/mitm-proxy.rb#L152-L154

if ARGV[0] == '--init'
  init_ca_cert
end

初回起動時は --init オプションを付けるものとします。 その場合は CA 証明書を生成します。

https://github.com/masm11/mitm-proxy2/blob/master/mitm-proxy.rb#L156

ca_pkey, ca_cert = read_ca_cert

CA の秘密鍵と証明書を読み込みます。

Socket.tcp_server_loop('127.0.0.1', 8000) do |sock, addr|
  fork do
    serve(ca_pkey, ca_cert, sock)
    exit(0)
  end
  sock.close
end

TCP のサービスを書く時、接続を受け付けたらプロセスを fork し、 新しいプロセスに処理を任せて、本体は引き続き次の接続を待つ、 という作り方をよくします。今回もそうしました。

127.0.0.1:8000 をプロキシ待ち受けアドレスとします。 そこで接続を待ち受け、接続があったらブロックが呼ばれます。 ブロックの中では、fork して serve メソッドで処理します。 親プロセスではソケットが不要なので閉じておきます。

なお、ここで閉じ忘れると、通信相手 (ブラウザや curl) が 切断を認識できなくてハマります。

処理する

では serve メソッドを見ていきます。

https://github.com/masm11/mitm-proxy2/blob/master/mitm-proxy.rb#L99-L150

  line = sysread_line(sock).chomp
  if line !~ /\ACONNECT\s+(.*):(\d+)\s/
    raise 'Bad connect: ' + line
  end
  host = $1
  port = $2.to_i

ソケットから 1行読みます。読んだものが CONNECT であることを確認して、 接続先サーバ名とポート番号を取り出します。

なお、Ruby には IO#readIO#sysread が用意されていますが、 これらは混ぜて使うとおかしな挙動になります。 OpenSSL が sysread を使っているようなので、私も sysread を使うことにしました。

IO#readlineIO#read を使っていて、ここでは使えませんので、 sysread 版を自分で作りました。それが sysread_line です。

  loop do
    line = sysread_line(sock).chomp
    break if line == ''
  end

CONNECT 以降の HTTP ヘッダの処理です。ここでは読み飛ばしているだけです。 空行が現れたら HTTP ヘッダの終わりです。

  cs = TCPSocket.open(host, port)
  cs = OpenSSL::SSL::SSLSocket.new(cs)
  cs.connect
  cs.post_connection_check(host)

接続先が得られたので、接続します。TCP で接続し、それを OpenSSL に渡して、 cs.connect で TLS ハンドシェイクをしています。cs.post_connection_check では 証明書の中の CommonName が正しいかをチェックしています。

  sock.syswrite "HTTP/1.1 200 OK\r\n\r\n"

ここまで成功したら、ブラウザに成功レスポンスを返します。

sysread と同様、syswrite を使います。

pkey, cert = get_cert(ca_pkey, ca_cert, host)

サーバの自前秘密鍵と証明書を取得しています。このメソッドは先程上で用意したものです。

  ctxt = OpenSSL::SSL::SSLContext.new('TLSv1_2_server')
  ctxt.cert = cert
  ctxt.key = pkey

ブラウザと TLS ハンドシェイクするための準備です。 TLS のバージョン、証明書、秘密鍵をセットしています。

サーバ側としてハンドシェイクするので、少々手間がかかります。

  sock = OpenSSL::SSL::SSLSocket.new(sock, ctxt)
  sock.accept

ソケットを OpenSSL に渡して、ハンドシェイクしています。

以上で、プロキシ-ブラウザとプロキシ-サーバが TLS で接続できました。 ここから先は、ソケット間の橋渡しをしているだけです。

      rs = IO.select([sock, cs])

ブラウザ側接続またはサーバ側接続のどちらかからデータを読めるようになるまで待ちます。

      rs = rs.first.first

読めるようになったら、この場所にソケットが入っていますので、取り出します。

      if rs == sock
        buf = sock.sysread(1024)
        if buf.nil?    # EOF
          break
        end
        cs.syswrite(buf)

ブラウザ側ソケットから読めるようになった場合の処理です。 最大 1024バイト読んで、EOF でなければサーバ側ソケットへ送ります。

      elsif rs == cs
        buf = cs.sysread(1024)
        if buf.nil?    # EOF
          break
        end
        sock.syswrite(buf)

逆向きの場合の処理です。

いずれかが EOF になった場合は、ループを抜け、プロセスを終了します。 その時に接続が切れます。

試す!

ブラウザで試せばいいのでしょうが、CA 証明書をブラウザに設定する必要がある等、 手間ですので、今回は curl で試します。

https_proxy=http://localhost:8000 curl --cacert ./ca.crt https://www.masm11.me/

プロキシの場所を環境変数で渡しています。

--cacert ./ca.crt で CA 証明書を指定しています。この ca.crt は MITM プロキシが 生成した CA 証明書のファイル名です。

これで試したところ、

luna:mitm-proxy % https_proxy=http://localhost:8000 curl --cacert ./ca.crt https://www.masm11.me/
<!DOCTYPE html>
<html>
<head>
<title>Welcome to nginx!</title>
<style>
    body {
(以下略)

通信できました!

まとめ

以上、MITM プロキシの説明をしてみました。

最後に説明したループの中で、buf には通信内容が平文で入っています。

今回はブラウザではテストしませんでした。実はブラウザでのテストは大変なのです。 serial が戻ってはいけないし、同じ serial で別の証明書ではいけないし...

インゲージではエンジニアを募集しています。 こんな話題に花を咲かせたいそこのあなた! ぜひうちで働きませんか? (業務ではこんなコードは書きませんが・・) 詳細は以下のページへ!

https://ingage.co.jp/recruit

ではまた!