VaultでDynamic Secretsを試してみる

目次

  1. 初めに
  2. 環境情報
  3. Dynamic Secretsについて
  4. AWS Secrets Engineの準備
  5. Roleを定義する
  6. IAM Userを作成する
  7. まとめ

1. 初めに

VaultではDynamic Secretsという機能がありますので、今回はこちらの機能について触れていきたいと思います。 今回もHashiCorp Learnにそって進めていき、補足情報などを備忘録として残していこうと思います。

Vault Curriculum - HashiCorp Learn

2. 環境情報

3. Dynamic Secretsについて

まずはDynamic Secretsがどういうものなのかについて説明したいと思います。通常のSecretはCLIなどで追加をしていました。

例えば kv の場合ですと下記のようにしてSecretを追加します。

$ vault write kv/hello target=world  

ただこうして追加されたSecretは他のユーザから見られてしまうので、セキュリティ的によくないですね。

そこでDynamic Secretsを使うことでこの問題が解決されます。

Dynamic Secretsは動的にSecretを作成する方法です。Secretを動的に作成し使用後すぐに消すことも可能ですし、TTLを指定することができます。また、動的に作成され作成したものは後から見ることができないのでセキュアに扱うことができます。

文字だけで説明してもDynamic Secretの良さが伝わらないので実際にコマンドと一緒に確認していきたいと思います。

HashiCorp LearnではAWS Secrets Engineを使っています。これはAWSへアクセスるためのクレデンシャルを作成してくれるものになります。 ここも詳しくは実際のコマンドを見ながら確認していきましょう。

4. AWS Secrets Engineの準備

まずは AWS Secrets Engineをenableにしましょう

$ vault secrets enable -path=aws aws
Success! Enabled the aws secrets engine at: aws/

$ vault secrets list
Path          Type         Accessor              Description
----          ----         --------              -----------
aws/          aws          aws_df70dbb8          n/a
cubbyhole/    cubbyhole    cubbyhole_18f07d22    per-token private secret storage
identity/     identity     identity_5170f551     identity store
secret/       kv           kv_2fd4ecf6           key/value secret storage
sys/          system       system_4a94a9f3       system endpoints used for control, policy and debugging

これでenableになってるのが確認できました。ちなみにDescriptionが n/a になっているのはDescriptionを何も設定してないっということです。これはオプションで設定することができます。

$ vault secrets enable -path=aws -description="aws secrets engine" aws
Success! Enabled the aws secrets engine at: aws/

AWS Secrets Engineは上記でも記述した通り、動的にAWSへのアクセス権限を作成します。

次にAWS Secrets Engineのconfig設定をしていきます。

$ vault write aws/config/root access_key=XXXXXXXXXXXXXXXXXXXX secret_key=XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX region=ap-northeast-1
Success! Data written to: aws/config/root

access_keyとsecret_keyはVaultからAWSへアクセスするときに使用するものになります。

権限は以下のものを利用しました。

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Sid": "VisualEditor0",
            "Effect": "Allow",
            "Action": [
                "iam:DeleteAccessKey",
                "iam:AttachUserPolicy",
                "iam:DeleteUserPolicy",
                "iam:DeleteUser",
                "iam:ListUserPolicies",
                "iam:CreateUser",
                "iam:CreateAccessKey",
                "iam:RemoveUserFromGroup",
                "iam:ListGroupsForUser",
                "iam:PutUserPolicy",
                "iam:ListAttachedUserPolicies",
                "iam:DetachUserPolicy",
                "iam:ListAccessKeys"
            ],
            "Resource": "arn:aws:iam::*:user/*"
        }
    ]
}

5. Roleを定義する

Roleを定義していきます。Roleの定義をVaultに保存していきます。 今回追加するRoleは以下のものになります。

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "Stmt1426528957000",
      "Effect": "Allow",
      "Action": ["ec2:*"],
      "Resource": ["*"]
    }
  ]
}

このRoleはすべてのEC2へすべてのAction権限があるPolicyになります。 ではこれをVaultに保存します。

$ vault write aws/roles/my-role \
        credential_type=iam_user \
        policy_document=-<<EOF
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "Stmt1426528957000",
      "Effect": "Allow",
      "Action": [
        "ec2:*"
      ],
      "Resource": [
        "*"
      ]
    }
  ]
}
EOF

$ vault read aws/roles/my-role
Key                         Value
---                         -----
credential_type             iam_user
default_sts_ttl             0s
max_sts_ttl                 0s
permissions_boundary_arn    n/a
policy_arns                 <nil>
policy_document             {"Version":"2012-10-17","Statement":[{"Sid":"Stmt1426528957000","Effect":"Allow","Action":["ec2:*"],"Resource":["*"]}]}
role_arns                   <nil>
user_path                   n/a

ここれで credential_typeiam_user を指定しました。

これはIAM Userを作成するRoleとして定義しており、Policyはインラインポリシーとして定義されます。 iam_user 以外にも下記のようなものがあります。

タイプ 説明
assumed_role sts:AssumeRole
federation_token sts:GetFederationToken

それぞれの細かい説明は省きます。詳しく知りたい方は下記リンクを参照してください。

AWS - Secrets Engines - Vault by HashiCorp

初めてのAssumeRole | Developers.IO

GetFederationToken のアクセス権限

6. IAM Userを作成する

では先ほど作成したRoleでIAM Userを作成しようと思います。

IAM Userを作成するには aws/creds/[作成したRoleの名前] パスを読むことで作成されます。上記で my-role でRoleを作成したので今回は aws/creds/my-role を読むことで作成されます。

$ vault read aws/creds/my-role
Key                Value
---                -----
lease_id           aws/creds/my-role/nDPEvmAlgRUBdNx7ChuRuhRU
lease_duration     768h
lease_renewable    true
access_key         YYYYYYYYYYYYYYYYYYYY
secret_key         YYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYY
security_token     <nil>

実際にAWSコンソールの画面で見てみると新規にIAM Userが作成されているのが確認できます。また出力された そしてもう一度実行すると新規でIAM Userが作られて新しい出力を得ることができます。

また -format=json を指定するだけで出力はJSON形式でも出力ができます。

$ vault read -format=json aws/creds/my-role
{
  "request_id": "d73d780e-a5cc-e30f-df5d-ae7e72e832f3",
  "lease_id": "aws/creds/my-role/eNmGkM06f3yzv9ZgvZVUx6Uh",
  "lease_duration": 2764800,
  "renewable": true,
  "data": {
    "access_key": "ZZZZZZZZZZZZZZZZZZZZ",
    "secret_key": "ZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZ",
    "security_token": null
  },
  "warnings": null
}

最後に削除処理です。IAM Userを削除するときはIAM Userを作成したときに出力された lease_id 引数にして削除します。

$ vault lease revoke aws/creds/my-role/nDPEvmAlgRUBdNx7ChuRuhRU
All revocation operations queued successfully!

AWSコンソールからも削除されているのが確認できます。

7. まとめ

今回はAWS IAM Userを動的に作成し、access keyとsecret keyの取得までできるようになりました。

HashiCorp Learnをそって進めるだけでここまでできるので気になる方はぜひやってみてください。 AssumeRoleなどのほうが利便性も良さそうなので機会があるときにやってみようと思います。

Vault初めてみる : Secretの基礎

目次

  1. 初めに
  2. 環境情報
  3. vault serverの起動
  4. Secretの保存
  5. Secret Engineについて触れておく
  6. 終わりに

1. 初めに

Vaultの勉強でHashiCorp Learnをやってみました。その時にやった内容や補足で調べたことなどを備忘録として残していきたいと思います。

また、HashiCorp Learnのサイトは下記リンクから確認できます。コマンド等は本家のサイトとほぼ、同様になっています。

Vault Curriculum - HashiCorp Learn

このブログよりも細かい説明もHashiCorp Learnに記載されていますので、気になる方は合わせてご確認ください。

今回はVault事態の説明は省いていますので、気になる方は下記サイトなどを参考にしてください。

Vault by HashiCorp

HashiCorp Vaultの基礎知識と導入 | Developers.IO

2. 環境情報

3. Vault Serverの起動

まずはVault Serverを起動させてみます。HashiCorp Learnでは dev modeでServerを起動させていますので、サンプルにそって実行させてみましょう。 Serverはセキュアなものではないので本番での利用は推奨されてませんので気を付けましょう。

$vault server -dev
==> Vault server configuration:

             Api Address: http://127.0.0.1:8200
                     Cgo: disabled
         Cluster Address: https://127.0.0.1:8201
              Listener 1: tcp (addr: "127.0.0.1:8200", cluster address: "127.0.0.1:8201", max_request_duration: "1m30s", max_request_size: "33554432", tls: "disabled")
               Log Level: info
                   Mlock: supported: true, enabled: false
           Recovery Mode: false
                 Storage: inmem
                 Version: Vault v1.3.0
             Version Sha: 4083c36aa0630faafb7c04be62c4940299880bc9+CHANGES

WARNING! dev mode is enabled! In this mode, Vault runs entirely in-memory
and starts unsealed with a single unseal key. The root token is already
authenticated to the CLI, so you can immediately begin using Vault.

You may need to set the following environment variable:

    $ export VAULT_ADDR='http://127.0.0.1:8200'

The unseal key and root token are displayed below in case you want to
seal/unseal the Vault or re-authenticate.

Unseal Key: UoL9YBIsRrIxBFbfO+cfpmEHyUyzL8DH57CXWm7aI4E=
Root Token: s.VGnMIQk4ogkD2Yn42O4GT0Zr

Development mode should NOT be used in production installations!

Serverが実行されるとフォワグラウンドで実行されます。ですので別のターミナルを使って続きをやっていきましょう。 ここでServerを起動したときに出力されたServerのエンドポイントとRoot Token情報新しく立ち上げたターミナルの環境変数として登録します。

export VAULT_ADDR='http://127.0.0.1:8200'
export VAULT_DEV_ROOT_TOKEN_ID="s.VGnMIQk4ogkD2Yn42O4GT0Zr"

VAULT_ADDRは出力された文字内に環境変数の設定まできれいに記載されています。 VAULT_DEV_ROOT_TOKEN_IDは Root Token の値になります。

環境変数を設定した後にステータスを確認してみると下記のような結果になります。

$ vault status
Key             Value
---             -----
Seal Type       shamir
Initialized     true
Sealed          false
Total Shares    1
Threshold       1
Version         1.3.0
Cluster Name    vault-cluster-9bdecbfc
Cluster ID      e619f604-2e79-b963-55e0-c3f73e1e860c
HA Enabled      false

これでCLIでVault Serverを操作する準備ができました。では次にSecretを保存してみましょう。

4. Secretの保存

まずSecretとはどういうものかというと、APIキーやユーザ/パスワードなどの機密情報のことです。 そしてこのSecretを管理するのがSecrets Engineになります。 HashiCorp Learnでは kv っというSecrets Engineを使っていますので、まずはその挙動を見てみましょう。

$ vault kv put secret/hello foo=world
Key              Value
---              -----
created_time     2019-12-22T08:38:13.005825481Z
deletion_time    n/a
destroyed        false
version          1

ここでは foo=world っというペアで secret/hello っというパスに保存をしました。パスっというのは各Secrets Engineで独自のパスとプロパティを定義されています。 dev modeで起動しているServerでは kv はデフォルトでenableになっており、パスは /secret を使うようになっています。 ちなみに、 kv はversion 1とversion 2があり、デフォルトでenableになっているのはversion 2のほうになります。

サンプルにそってもう一つ、ペアを登録してみましょう。

$ vault kv put secret/hello foo=world excited=yes
Key              Value
---              -----
created_time     2019-12-22T08:47:51.195186928Z
deletion_time    n/a
destroyed        false
version          2

では、登録したSecretを確認してみましょう。

$ vault kv get secret/hello
====== Metadata ======
Key              Value
---              -----
created_time     2019-12-22T08:47:51.195186928Z
deletion_time    n/a
destroyed        false
version          2

===== Data =====
Key        Value
---        -----
excited    yes
foo        world

無事に登録されているのが確認できました。 ちなみに vault kv put 処理は追加ではなく、上書となるので注意が必要です。

kv で保存されたSecretで特定のKeyのものだけを取り出すこともできます。

$ vault kv get -field=excited secret/hello
yes

最後に、不要となったSecretを削除する処理は下記のようになります。

$ vault kv delete secret/hello
Success! Data deleted (if it existed) at: secret/hello

ここでの delete 処理は secret/hello の削除ではなく、 secret/hello の中にあるSecretのみ削除となります。 ですので下記のように secret/hello をgetしたときに情報が返ってきます。

$ vault kv get secret/hello
====== Metadata ======
Key              Value
---              -----
created_time     2019-12-22T10:24:36.600075751Z
deletion_time    2019-12-22T10:32:10.159743478Z
destroyed        false
version          2

5. Secret Engineについて触れておく

先ほどの secret/hello はSecrets Engine独自のパスとプロパティっていう記載しました。 もう少し掘り下げると、「パスによってSecrets EngineがなにかをVaultへ指示している」っということになります。 リクエストのパスによってSecrets Engineが特定されるようになるので、各Secrets Engineによって独自のパスになるということです。 パスとSecrets Engineの対応を見る方法は下記のコマンドで確認できます。

$ vault secrets list
Path          Type         Accessor              Description
----          ----         --------              -----------
cubbyhole/    cubbyhole    cubbyhole_18f07d22    per-token private secret storage
identity/     identity     identity_5170f551     identity store
secret/       kv           kv_2fd4ecf6           key/value secret storage
sys/          system       system_4a94a9f3       system endpoints used for control, policy and debugging

これで kv のパスが secret/ になっているのが確認できます。 では kv のSecrets Engineを別のパスで一つ追加してみたいと思います。

$ vault secrets enable -path=kv kv
Success! Enabled the kv secrets engine at: kv/

$ vault secrets list
Path          Type         Accessor              Description
----          ----         --------              -----------
cubbyhole/    cubbyhole    cubbyhole_18f07d22    per-token private secret storage
identity/     identity     identity_5170f551     identity store
kv/           kv           kv_30fe82bc           n/a
secret/       kv           kv_2fd4ecf6           key/value secret storage
sys/          system       system_4a94a9f3       system endpoints used for control, policy and debugging

kv/ っというパスで kv Secret Engineが追加されました。 ではSecretを追加していきたいと思います。

$ vault write kv/my-secret value="s3c(eT"
Success! Data written to: kv/my-secret

$ vault write kv/hello target=world
Success! Data written to: kv/hello

$ vault write kv/airplane type=boeing class=787
Success! Data written to: kv/airplane

$ vault list kv
Keys
----
airplane
hello
my-secret

最後にSecrets Engineを無効にします。

$ vault secrets disable kv/

6. 終わりに

HashiCorp Learnを参考にVault Serverを立ち上げてSecretの保存、Secrets Engineの概要について勉強してきました。 引き続きHashiCorp Learnを続けていきたいと思います。

Elixirで簡単なProxyサーバを自作した

どうもこんにちは。ここ最近Erlangを書いてたり、AWS、OpenStackで遊んでいましたが久々にElixirを書いています。 今回はElixirでProxyサーバを自作しているので、どのような実装になっているか備忘録的に書いていきます。

動作環境

全体的な流れ

Proxyサーバを実装するにあたって、全体的な流れは以下のようにしています。

  1. Supervisorにリクエストを待ち受けるworkerを複数件登録
  2. workerでリクエストを待ち受ける
  3. バックエンド側(HTTPサーバ)へのコネクションを確立させる
  4. フロントとバックエンド、それぞれのソケットで受け取ったパケットの送受信をする
  5. フロントかバックエンド側どちらかのソケットがクローズしたら、もう片方もクローズする

workerはSupervisorに登録していき、それぞれのworkerでパケットの処理をしていきます。 また、コネクションごとにworkerは一つとなり、コネクション数ごとにworkerが増えていく想定となっています。

Supervisorにリクエストを待ち受けるworkerを複数件登録

では、これから実装内容を見ていきます。リクエストを待ち受けるworkerを複数件事前に作成し、Supervisorに登録していきます。

  def init(listen) do
    # backend
    hosts = [
      %Exdeath.ProxyNode{
        host: {192, 168, 33, 61},
        port: 8010,
      }
    ]
    spawn(__MODULE__, :start_workers, [listen, hosts])
    {:ok, { {:one_for_one, 6, 60}, []} }
  end
  
  def start_workers(listen, hosts) do
    for _ <- 1..100, do: start_worker(listen, hosts)
  end

  def start_worker(listen, hosts) do
    pid = make_ref()
    child_spec = %{
      id: pid,
      start: {Exdeath.Proxy, :start_link, [listen, hosts, pid]},
      restart: :temporary,
      shutdown: :brutal_kill,
      type: :worker
    }
    Supervisor.start_child(__MODULE__, child_spec)
  end

では簡単に、要点ごとに説明してきたいと思います。

    spawn(__MODULE__, :start_workers, [listen, hosts])

まずは start_workers を別プロセスとして実行します。 hsots はバックエンド側になるサーバをリストとして登録しています。バックエンド側のサーバを複数件登録し、LB知るようにしようと思っていますが、 今回はLBの実装は省略して進めたいと思います。

  def start_workers(listen, hosts) do
    for _ <- 1..100, do: start_worker(listen, hosts)
  end

start_workers でSupervisorに登録する件数分、ループ(リスト内包表記)を回します。今回は100件登録されています。

  def start_worker(listen, hosts) do
    pid = make_ref()
    child_spec = %{
      id: pid,
      start: {Exdeath.Proxy, :start_link, [listen, hosts, pid]},
      restart: :temporary,
      shutdown: :brutal_kill,
      type: :worker
    }
    Supervisor.start_child(__MODULE__, child_spec)
  end

実際のSupervisorへの登録処理です。Exdeath.Proxy をworkerとして起動させています。 pidには make_ref() で取得した値を入れ、それをworkerの名前として登録します(後で出てきます)。 ここで、listenも workerの起動時の引数として渡していますが、これは gen_tcp で listen しているソケットになります。

workerでリクエストを待ち受ける

Supervisorで登録までできたので、ここから個別のworkerでの処理を見ていきましょう。

  use GenServer
  def start_link(listen, hosts, pid) do
    GenServer.start_link(__MODULE__, [listen, hosts], name: {:global, pid})
  end

  def init([listen, hosts]) do
    GenServer.cast(self(), :accept)
    {:ok, %{listen: listen, front: nil, back: nil, hosts: hosts}}
end

  def handle_cast(:accept, %{listen: listen}=state) do
    with {:ok, socket} <- :gen_tcp.accept(listen)
    do
      :gen_tcp.controlling_process(socket, self())
      {:noreply, %{state| front: socket}}
    else
      {:error, :eaddrinuse} ->
        {:stop, {:shutdown, :eaddrinuse}}
      {:error, reason} ->
        {:stop, {:shutdown, reason}}
      error ->
        {:stop, {:shutdown, error}}
    end
  end

workerはGenServerを利用しています。GenServerで実行するとパケットが送られてきたときに handle_info のメソッドで処理できるので、このようにしています。

  def start_link(listen, hosts, pid) do
    GenServer.start_link(__MODULE__, [listen, hosts], name: {:global, pid})
  end

ここでGenServerとして実行し、リンクしています。ここで先ほどSupervisorで作ったpidをnameにセットします。

  def init([listen, hosts]) do
    GenServer.cast(self(), :accept)
    {:ok, %{listen: listen, front: nil, back: nil, hosts: hosts}}
end

次に初期化処理ですが、ここで GenServer.cast(self(), :accept) を実行し、リクエストの待ち受けを非同期で行えるようにします。

  def handle_cast(:accept, %{listen: listen}=state) do
    with {:ok, socket} <- :gen_tcp.accept(listen)
    do
      :gen_tcp.controlling_process(socket, self())
      {:noreply, %{state| front: socket}}
    else
      {:error, :eaddrinuse} ->
        {:stop, {:shutdown, :eaddrinuse}}
      {:error, reason} ->
        {:stop, {:shutdown, reason}}
      error ->
        {:stop, {:shutdown, error}}
    end
  end

メインとなるリクエストを待ち受けてる状態となります。このworkerがリクエストを取得するとフロント側のソケットが開き、そのソケットからのメッセージをこのプロセス(self)が受け付けられるよう :gen_tcp.controlling_process(socket, self()) を実行します。

バックエンド側(HTTPサーバ)へのコネクションを確立させる

フロント側のコネクションを確立させたので、次はバックエンド側となります。

  def handle_info({:tcp, socket, packet}, %{front: socket, back: nil, hosts: [host| _]}=state) do
    {:ok, backend_socket} = set_connect(host)
    end(backend_socket, packet)
    {:noreply, %{state| back: proxy_host}}
  end
  
    def set_connect(proxy) do
    with {:ok, conn} <- :gen_tcp.connect(proxy.host, proxy.port, [:binary, packet: 0])
    do
      {:ok, %{proxy| conn: conn}}
    else
      error ->
        error
    end
  end
  
  def send(socket, packet) do
    :gen_tcp.send(socket, packet)
  end

バックエンドのコネクションの確立にはフロント側からパケットを受け取った時に行うようにしています。

def set_connect(proxy) do
    with {:ok, conn} <- :gen_tcp.connect(proxy.host, proxy.port, [:binary, packet: 0])
    do
      {:ok, %{proxy| conn: conn}}
    else
      error ->
        error
    end
  end

ここでTCPのコネクション確立のためバックエンドのサーバへリクエストを送ります。

  def send(proxy, packet) do
    :gen_tcp.send(proxy.conn, packet)
  end

先ほどのバックエンドとのコネクションで確立したソケットに対して、パケットを送信します。 これで、フロントから受け取ったパケットをバックエンドのサーバへ送信することができるようになりました。

フロントとバックエンド、それぞれのソケットで受け取ったパケットの送受信をする

最後にそれぞれのソケット(フロントとバックエンド)で受け取ったパケットを送信するようにします。

  def handle_info({:tcp, socket, packet}, %{front: socket, back: back}=state) do
   send(back, packet)
    {:noreply, state}
  end

  def handle_info({:tcp, socket, packet}, %{front: front, back: %{conn: socket}}=state) do
    send(front, packet)
    {:noreply, state}
  end

ここではパターンマッチを使ってフロントから来たパケットなのか、バックエンドから来たパケットなのかを判断し、それぞれ別方向へパケットを送信します。

フロントかバックエンド側、どちらかのソケットがクローズしたらもう片方もクローズする

最後にソケットのクローズ処理です。

  def handle_info({:tcp_closed, socket}, %{front: nil, back: socket}=state) do
    {:stop, :shutdown, state}
  end
  def handle_info({:tcp_closed, socket}, %{front: front, back: %{conn: socket}}=state) do
    :gen_tcp.close(front)
    {:stop, :shutdown, state}
  end

これも、パケットの送信同様、パターンマッチでどっちのソケットがクローズしたかを判断し、もう片方のソケットをクローズするようにします。

まとめ

Proxy サーバはErlangでも実装したことがあり、その実装を参考にElixirでも実装してみました。

Erlangで簡易的なTCP Proxyを作った - ネットワークプログラマを目指して

Proxyを使えばいろいろと応用的な実装もできそうなので次はそのことについてまとめていきたいと思います。

ErlangでHTTPリクエストをパースする

どうもこんにちは。前回に引き続きErlangでProxyを作っています。HTTP ProxyでURIごとにルーティングをさせる。さらにProxyを経由してHTTPリクエストをするので「X-Forwarded-For」をHeaderに追加させる必要があります。そのためHTTPリクエストの簡単なパース処理を実装しました。その内容をまとめていきます。

前回の内容

kobatako.hatenablog.com

動作環境

Erlangで文字列処理

まずはErlangでの文字列処理について。そもそもErlangでの文字列は他の言語の文字とは少し違い、リスト、バイナリになります。Erlangで文字列を表現するときは以下のようになります。

> [97, 98, 99].
"abc"
> is_list("abc").
true
> is_binary("abc").
false
> is_binary(<<"abc">>).
true

is_list("abc").の結果がtrueっということは"abc"はリストになります。つまり文字列はリストで表現されてます。 そしてもう一つの表現方法としてバイナリ文字列として文字列を表現することもできます。 Erlangでのリストとバイナリ、2つの文字列の違いはメモリ空間の使い方が大きく違います。リストは単方向リストとなりますがバイナリ文字列は一つの塊としてメモリに保存されます。

HTTPリクエストのフォーマット

HTTPのパケットは基本的に文字列になります。ヘッダー情報もすべて文字列となるので文字列を操作する必要があります。 まずはHTTPリクエストの内容を見てみましょう。

<<"GET /hello?hoe=test HTTP/1.1\r\nHost: 127.0.0.1:5555\r\nUser-Agent: curl/7.58.0\r\nAccept: */*\r\n\r\n">>

これはErlangでキャプチャしたものになります。バイナリ文字としてパケットを取得しています。各要素について見ていきましょう。

GET はHTTPリクエストのメソッドですね。 /hello?hoe=testっというURIでリクエストが来ています。 HTTP/1.1 はHTTPのバージョンになります。

そして残りの部分がHeader部分になります。 これをErlangでパースします。

パース処理

全体的なパース処理を見てみましょう。

parse(Buffer) ->
  {ok, Method, Res0} = parse_method(Buffer, <<>>),
  {ok, Uri, Res1} = parse_uri(Res0, <<>>),
  {ok, Ver, Res2} = parse_version(Res1),
  {ok, Header} = parse_header(Res2, []),
  #{method => Method, uri => Uri, version => Ver, header => Header}.

こんな感じでパース処理を実装しました。それぞれのパース処理は以下のようになります。

parse_method : メソッド部分のパース parse_uri : URI部分のパース parse_version : バージョン部分のパース parse_header : ヘッダー部分のパース

そしてすべての結果をマップとして返します。 特に実装は複雑にはしてなく、エラー処理等は省いています。

メソッド部分

まずは

  {ok, Method, Res0} = parse_method(Buffer, <<>>),

の部分を見ていきます。

parse_method(<<?BLANK, Res/binary>>, Method) when ?IS_HTTP_METHOD(Method) ->
  {ok, Method, Res};
parse_method(<<C, Res/binary>>, Method) ->
  parse_method(Res, <<Method/binary, C>>).

これだけで実装しました。 ?BLANKはdefineで空白文字を定義しておきます。

-define(BLANK, $\s).

$\sアスキーコードで32を表してます。アスキーコードの32は空白文字になるので空白文字が出るまで第二引数のバイナリに先頭の文字を追加していきます。 そして、空白文字が出たときに第二引数の文字、つまりメソッドがちゃんとHTTPのメソッドなのか IS_HTTP_METHOD で確認します。

-define(GET, <<"GET">>).
-define(POST, <<"POST">>).
-define(PUT, <<"PUT">>).
-define(DELETE, <<"DELETE">>).
-define(IS_HTTP_METHOD(H),
              (H =:= ?GET) or (H =:= ?POST)
           or (H =:= ?PUT) or (H =:= ?DELETE)).

IS_HTTP_METHODdefineで定義し、ガードの中で使っています。 これでメソッドが正しいかどうかチェックをしています。

URIのパース処理

先程のメソッドと同じようにURIもパースします。

parse_uri(<<?BLANK, Res/binary>>, Uri) ->
  {ok, Uri, Res};
parse_uri(<<C, Res/binary>>, Uri) ->
  parse_uri(Res, <<Uri/binary, C>>).

バージョンのパース処理

バージョンだけは長さが固定長になっているのでもっと楽に取り出しましょう。

parse_version(<<"HTTP/1.0\r\n", Res/binary>>) ->
  {ok, http_1_0, Res};
parse_version(<<"HTTP/1.1\r\n", Res/binary>>) ->
  {ok, http_1_1, Res};
parse_version(<<"HTTP/2.0\r\n", Res/binary>>) ->
  {ok, http_2_0, Res};
parse_version(Res) ->
  {error, not_match_http_version, Res}.

そのままパターンマッチで処理をします。 それぞれアトムを定義して、後ほどこれをいい感じに処理をできるようにします。

ヘッダーのパース処理

今回で一番めんどくさいヘッダーのパース処理です。 では、ヘッダーのパース処理を見ていきましょう。

parse_header(<<$\r, $\n, $\r, $\n>>, Headers) ->
  {ok, Headers};
parse_header(<<$\r, $\n, _/binary>>, Headers) ->
  {ok, Headers};
parse_header(<<Res0/binary>>, Headers) ->
  {ok, Header, Res} = parse_header_field(Res0, <<>>),
  parse_header(Res, [Header| Headers]).

parse_header_field(<<>>, _) ->
  {error, not_header_field};
parse_header_field(<<$:, Res0/binary>>, Field) ->
  {ok, Value, Res} = parse_header_value(Res0, <<>>),
  {ok, {Field, string:trim(Value)}, Res};
parse_header_field(<<C, Res/binary>>, Field) ->
  parse_header_field(Res, <<Field/binary, C>>).

parse_header_value(<<>>, _) ->
  {error, not_header_value};
parse_header_value(<<$\r, $\n, Res/binary>>, Value) ->
  {ok, Value, Res};
parse_header_value(<<C, Res/binary>>, Value) ->
  parse_header_value(Res, <<Value/binary, C>>).

全体のヘッダーのパースをするparse_headerと各ヘッダー項目をパースするためフィールドと値を取り出す関数を別々にして実装しています。 parse_header_field でヘッダーのフィールド部分を取り出し、 parse_header_value でフィールドの値を取り出します。

最終的なパースした結果はこうなります。

#{header =>
      [{<<"Accept">>,<<"*/*">>},
       {<<"User-Agent">>,<<"curl/7.58.0">>},
       {<<"Host">>,<<"127.0.0.1:5555">>}],
  method => <<"GET">>,uri => <<"/hello?hoe=test">>,version => http_1_1}

ヘッダー部分もフィールドと値でタプルの配列として取れましたし、メソッド、URIバージョンも無事にマップとして取り出せました。

まとめ

  • バイナリ文字列をそのままパースしていきました。パース自体も簡単なものだったのでサクサクっと作ることができました。
  • 今回のような簡単な文字列操作なら自前で実装できますが、もう少し複雑なものとなるとBIFでstringパッケージがあるのでそれを使えばいいかと。
  • まだパースしただけなのでURIでのルーティング処理を実装できてないんで実装を進めていきます。
  • ここまででバックグランド側のサーバには「X-Forwarded-For」がついてる状態でリクエストが行くようになりました。次はURIでのルーティング処理を実装していきたいと思います。

Erlangで簡易的なTCP Proxyを作った

どうもこんにちは。久々に記事書きます。 サボりにサボってやく一年ぶりぐらい(正確には11ヶ月ぶりぐらい)でしょうか。「ちゃんと更新しないとなぁ」っと思いつつこれぐらいに月日が立ってしまったのでこれからは真面目に更新しようと思います!

ErlangでのProxy

では早速本題に入っていきたいと思います。最近趣味でElixir、Erlangを触ることが多く、よく書いてます。そこでErlangでHTTPのProxyを作りたいと思い作り始めたのでました。まずは簡易的なTCPのProxyを作ったのでまとめたいと思います。

動作環境

実装内容

rebar3でアプリケーションの作成

まずはrebar3でアプリケーションを作成します。

rebar3 new app sample_proxy

作成したら、まずはメインとなるProxyのプロセスを作成します。

Proxyプロセスの初期化

まずはProxyプロセスの初期化処理を行っていきます。

start_link(Receive, Destination) ->
  init([Receive, Destination]).

init([Receive, Destination]) ->
  {ok, ListenSocket} = gen_tcp:listen(Receive,
      [binary, {active, false}, {reuseaddr, true}, {backlog, 64}]
  ),
  spawn_link(?MODULE, accept, [ListenSocket, Destination]),
  {ok, []}.

ここで、TCPサーバで待ち受けポートでlistenします。そしてここで新しくプロセスを作成します。 (start_linkを中継している理由はgen_serverとかとインターフェイスを揃えるためです) Receiveにはフロント側となるクライアントからのリクエストを待ち受けるポートを指定します。Destinationには転送先となるサーバ情報を渡します。

TCPリクエストのaccept

では新しく生成したプロセス内を見ていきます。

accept(ListenSocket, Destination) ->
  case gen_tcp:accept(ListenSocket) of
    {ok, Socket} ->
      BackId = spawn_link(?MODULE, proxy_accept, [Destination, self()]),
      loop(Socket, BackId);
    {error, closed} ->
      ok;
    {error, Reson} ->
      io:format("fail accept ~p~n", [Reson])
  end,
  spawn(?MODULE, accept, [ListenSocket, Destination]).

gen_tcp:acceptでListenSocketでlistenしているポートに対してアクセスが来るまで待機します。ここでリクエストが来るとcaseブロック内の処理が実行されます。 {ok, Socket}となると正しいリクエストとなり、転送先のサーバへ接続をするプロセスを生成します。 その後はループ処理。実際のメイン処理となります。

転送先のサーバと接続するプロセス

転送先へ接続するためのプロセスをaccept後に生成しているのでその中身を見ていきたいと思います。

proxy_accept([{host, Host}, {port, Port}], FrontId) ->
  {ok, Con} = gen_tcp:connect(Host, Port, [binary, {packet, 0}]),
  loop(Con, FrontId).

転送先のホストとポートを指定し、gen_tcp:connectで接続を行います。 その後に、接続したコネクション情報とacceptしたプロセスIDをもとにループ処理へ入ります。

リクエストを待受、送信する

ではここからがメイン、loop関数の中を見ていきたいと思います。

loop(Socket, SendPid) ->
  inet:setopts(Socket, [{active, once}]),

  receive
    {tcp, Socket, Message} ->
      SendPid ! {recv, Message},
      loop(Socket, SendPid);

    {tcp_closed, Socket} ->
      gen_tcp:close(Socket);

    {tcp_error, Socket, Reson} ->
      io:format("Handle error ~p on ~p~n", [Reson, Socket]);

    {recv, Message} ->
      gen_tcp:send(Socket, Message),
      loop(Socket, SendPid)
  end.

第一引数に待ち受けるソケット情報、第二引数に転送先となるプロセスIDを渡します。 転送先となるプロセスIDはクライアント側の場合は転送のサーバプロセスID、転送先のサーバプロセスの場合はクライアント側のプロセスID。これで相互にメッセージのやり取りするIDがわかるということです。

receiveでメッセージが来るまで待ち受けます。 {tcp, Socket, Message}で来た場合はこちらが予測していたメッセージなのでそのまま転送先のプロセスにメッセージを送ります。

{recv, Message}で上記のメッセージを受け取ります。これはクライアントプロセスだろうが転送先サーバのプロセスだろうが同じことですね。ここで受け取ったメッセージをgen_tcp:sendでお互いの接続している先へメッセージを送信します。

リザルト

では実際に試してみたいと思います。まずはProxyの起動から

> sample_proxy:start_link(5555, [{host, {127, 0, 0, 1}}, {port, 8010}]),

5555ポートで待ち受け、転送先は127.0.0.1の8010ポートへ転送します。 次に2つのターミナルを立ち上げ、片方は転送先のサーバとしてHTTPサーバとして起動させます。 もう片方はクライアントとして使います。 転送先のサーバは「Hello \nWorld \nCunked!」を返します。 ではやってみましょう。

$ curl http://127.0.0.1:5555
Hello
World
Cunked!

はい!無事に通りました。

最後に

これぐらいならサクサクっと作れましたし、TCPの中継だけなのでそこまで手間はかからなかったです。ただエラー処理やコネクション処理などもろもろしてないのでそこら辺はおいおい実装していきます。そして何よりHTTPのProxyを作りたいのでこれからURLベースでの転送、複数台の転送サーバを持ったときの挙動などを実装していきたいと思います。

golangでUDPを使ってみた

環境情報

go version : 1.8

os : kubuntu 16

やったこと

  1. UDPサーバの実装
  2. UDPクライアントの実装

UDPサーバの実装

UDPはステートレスなプロトコルなので、セッション情報を持たず一方的に送り付けるプロトコルです。 なのでTCPと比べて比較的に実装は楽ですね。(まぁプログラムのコード量自体はそんなに大差ないですが)

UDPで使う主要なメソッド

func net.ResolveUDPAddr(net, addr  string) (*UDPAddr, error)
func net.ListenUDP(net string, laddr *UDPAddr) (c *UDPConn, error)
func (c *UDPConn) ReadFromUDP(b []byte) (n int, addr *UDPAddr, error)
func (c *UDPConn) WriteToUDP(b []byte, addr *UDPAddr) (n int, error)

net.ResolveUDPAddrはTCPと一緒で名前解決してくれるメソッドです。サーバサイドではローカルのIPを指定して待ち受けるポートを指定します。

net.ListenUDPで実際にパケットが来るのを待ち受け状態になります。

ReadFromUDPでパケットが来た場合に内容を読み込み、WriteToUDPで実際にパケットを送ります。

例:

func main() {
    udpAddr, err := net.ResolveUDPAddr("udp", ":1201")
    if err != nil {
        fmt.Fprintf(so.Stderr, "Fatal Error ", err.Error())
        os.Exit(1)
    }

    conn, err := net.ListenUDP("udp", udpAddr)
    if err != nil {
        fmt.Fprintf(so.Stderr, "Fatal Error ", err.Error())
        os.Exit(1)
    }
    
    for {
        handleClient(conn)
    }
}

func handleClient(conn *net.UDPConn) {
    var buf [512]byte

    _, addr, err := conn.ReadFromUDP(buf[0:])
    if err != nil {
        fmt.Fprintf(so.Stderr, "Fataaaaal Error ", err.Error())
        os.Exit(1)
    }
    
    daytime   := time.Now().String()
    conn.WirteToUDP([]byte(daytime), addr)
}

UDPクライアントの実装

クライアントはほとんどサーバと一緒です。違いはクライアント側でリッスンせずに、パケットを送り付けるぐらいですかね。

func net.ResolveUDPAddr(net, addr  string) (*UDPAddr, error)
func net.DialUDP(net string, laddr, raddr *UDPAddr) (*UDPConn, error)

ResolveUDPAddrはサーバと一緒でUDPで送るIPとポートでマッピングを行います。ただサーバと違うのは送りたい先のIPとポートを指定します

DialUDPで実際にパケットを送る先のサーバを設定します

例:

func main() {
    udpAddr, err := net.ResolveUDPAddr("udp", "localhost:1201")
    if err != nil {
        fmt.Fprintf(so.Stderr, "Fataaaaal Error ", err.Error())
        os.Exit(1)
    }

    conn, err != net.DialUDP("udp", nil, udpAddr)
    if err != nil {
        fmt.Fprintf(so.Stderr, "Fataaaaal Error ", err.Error())
        os.Exit(1)
    }

    _, err = conn.Write([]byte("anything")
    if err != nil {
        fmt.Fprintf(so.Stderr, "Fataaaaal Error ", err.Error())
        os.Exit(1)
    }

    var buf [512]byte
    n, err := conn.Read(buf[0:])
    if err != nil {
        fmt.Fprintf(so.Stderr, "Fataaaaal Error ", err.Error())
        os.Exit(1)
    }

    fmt.Println(string(buf[0:n])
}

以上、UDPのサーバとクライアントの実装でした!

golangでTCPを使ってみた

環境情報

go version : 1.8

os : kubuntu 16

やったこと

  1. LookupPort
  2. TCP Client

LookupPort

現在、実行してるサービスのポートを調べることができます。

net.LookupPort(network, service string) (int, error)

networkは"tcp"か"udp"になります

serviceは実行しているサービス、例えば"telent"、"http"、"ssh"とか

exsample

func main() {
    port, err := net.LookupPort("tcp", "http")
    if err != nil {
        fmt.Println("Error : ", err.Error())
        os.Exit(2)
    }
    fmt.Println("Service port : ", port)
}

TCP Client

いよいよTCPの話です。

まずはTCP関連でよく使う構造体から

type TCPAddr struct {
    IP   IP
    Port int
    Zone string
}

IPとPortとZoneを内包しています。 ZoneはIPv6で使用するZoneになります。IPとPortはそのままの意味かな。

この構造体を作るためには下記のメソッドを使います

net.ResolveTCPAddr(net, addr string) (*TCPAddr, err)

netにはネットワークタイプを(tcp, tcp4, tcp6)、addrにはアドレスとポートを入れます。 addrのフォーマットは「IPアドレスorホスト名:ポート」になります 例:www.google.com:80

次にTCPAddr構造体を使って実際にサーバとつなぎ合わせます

net.DialTCP(net string, laddr, raddr *TCPAddr) (*TCPConn, error)

netにはResolveTCPAddr同様でtcp、tcp4、tcp6のどれかを入れ、laddr、raddrにはTCPAddrを入れます。 それぞれladdrはローカル、raddrはリモートを表しています。 なので接続したいサーバのTCPAddrはraddrに入れます。(laddrには基本的にはnilが入ります)

ここで取得したTCPConnを使い実際のメッセージのやり取りを行います。 このTCPConnにたいして、write、readを行いリモートのサーバと通信を行います。

(c *TCPConn) Write(b []byte) (int, error)

bには送るメッセージを入れます(そのまんま)

最後にサーバからレスポンスを受け取り、読み込みます

ioutil.ReadAll(io.Reader) ([]byte, error)

io.Readerから読み込みます。これはサーバからレスポンスをすべて受け取るまでブロックして、すべて受け取ってから読み込みを開始します。

これで一通りTCPの通信の流れができます。 TCPクライアントのサンプルとしてHTTP通信をしたいと思います

exsample

func main() {
    tcpAddr, err := net.ResolveTCPAddr("tcp4", "www.google.com:80")
    if err != nil {
        fmt.Println("net resolve TCP Addr error ")
        os.Exit(1)
    }
    conn, err := net.DialTCP("tcp", nil, tcpAddr)
    if err != nil {
        fmt.Println("net dial tcp error ")
        os.Exit(1)
    }
    _, err = conn.Write([]byte{"HEAD / HTTP/1.0 \r\n\r\n)
    if err != nil {
        fmt.Println("conn write error ")
        os.Exit(1)
    }
    result, err := ioutil.ReadAll(conn)
    if err != nil {
        fmt.Println("ioutil read all error ")
        os.Exit(1)
    }
    fmt.Println(string(result))
}

これでTCP通信ができるようになりました(やーー!!)