この記事は、Elixir Advent Calendar 2019 の9日目です。昨日は @matsubara0507 さんのひっっさしぶりに古の自分のコードを動かした話(久しぶりに thank_you_stars をビルドする)でした。
今日は、とあるデバイスを触りたくなって、それが Ethernet フレームを使うので、Elixir でやるにはどうすればよいのか調べてみたという話です。残念ながら目的地まで到達できなかったので「準備編」という扱いにしました。
EtherCAT を Elixir で使いたい
みなさん EtherCAT はご存知ですか。これは Ethernet の枠組みを使う制御用の通信規格です。IoT でデバイスを制御するときには GPIO や I2C や Pmod とか使いますよね。組込みだと ModBUS とか FLnet とか言うのがあります。その一つに EtherCAT というのがあります。詳しくは EtherCAT アドベントカレンダー の記事をご覧ください。私も 温故知新 IEEE802シリーズで EtherCAT を眺め直す なる記事を書いております。
EtherCAT はモロに産業用という感じで、制御盤とかPLC用の製品が多く、これまでホビーとか IoT で気軽に使うような技術ではありませんでした。私もちょっと前までは気にはしながら遠く眺めてました。
ところが、彗星のように(なんて月並みで古臭い形容)気軽に試せるボード が発売になりました。それも python によるサンプルコード付きで。「これは Elixir でも使いたい」と早速取り寄せました。python のコードがあるから、とにかく動くレベルにするのはあまり難しくないだろう… その甘い考えを打ち砕かれて私は迷走を開始します。この記事はその顛末の前半です。
既存の Elixir による EtherCAT プログラム
検索した感じだと以下があります。
- https://github.com/fhunleth/nerves_system_rpi3_with_ethercat
- https://github.com/HallLabs/nerves_system_bbb_with_ethercat
などがあります。前者は EtherLab を使うためのパッケージ、後者は Beagle Bone を使うパッケージのようです。どちらも Elixir 自体で制御部分を記述しているようではなさそうです。
EtherCAT は IP ではない
さて EtherCAT は Ethernet フレームを使いますが IP ではありません。IP でないので当然 UDP でも TCP でもありません。OSI参照モデルの第2層であるデータリンク層として Ethernet を定める規格 IEEE802.3 を使いますが、そこより上はインターネット系のプロトコルとは全く関係がないのです。つまり、いつも使ってるソケットの技はそのまま使えないのです。はて Elixir で Ethernet を直接扱うのはどうやるのでしょうか。そもそも使うことができるのでしょうか。
私が普段使ってる Elixir の実装を見ると
- Elixir
- Erlang VM (BEAM)
- UNIX 系 OS (MacOS やら Linux やら)
の構造を持っています。私の環境で Elixir で EtherCAT をドライブしたいなら、Elixir で MacOS や Linux の持ってるデバイスドライバをどう扱うかがわからないとなりません。これ Python や Ruby なら割と簡単に手に入ります1。当然 Elixir も…
いえいえ、そうではなかったんです。
Ethernet フレームを OS で直接入出力したい
Ethernet フレームを直接入出力する方法は、実は UNIX 系の OS には普通にあります。ただしシステムコールを使わないとなりませんので、ややかなり上級コースになります。お手近の unix shell で man socket
してみてください。それがヒントです。
MacOS で Ethernet を直接扱う
まず MacOS で socket システムコールを見てみます。
$ man socket
SOCKET(2) BSD System Calls Manual SOCKET(2)
NAME
socket -- create an endpoint for communication
SYNOPSIS
#include <sys/socket.h>
int
socket(int domain, int type, int protocol);
DESCRIPTION
socket() creates an endpoint for communication and returns a descriptor.
という説明が出てきます。man セクションが2というのにもちょっとビビりますね。もう少し先を読んでみます。
The domain parameter specifies a communications domain within which com-
munication will take place; this selects the protocol family which should
be used. These families are defined in the include file <sys/socket.h>.
The currently understood formats are
PF_LOCAL Host-internal protocols, formerly called PF_UNIX,
PF_UNIX Host-internal protocols, deprecated, use PF_LOCAL,
PF_INET Internet version 4 protocols,
PF_ROUTE Internal Routing protocol,
PF_KEY Internal key-management function,
PF_INET6 Internet version 6 protocols,
PF_SYSTEM System domain,
PF_NDRV Raw access to network device
The socket has the indicated type, which specifies the semantics of com-
munication. Currently defined types are:
SOCK_STREAM
SOCK_DGRAM
SOCK_RAW
PF_INET
とか SOCK_STREAM
とか SOCK_DGRAM
とか、ネットワークプログラミングしたことのある方ならおなじみのキーワードが出てきました。TCP や UDP でプログラムするならこのあたりを使いますね。この最後の SOCK_RAW はなんでしょうか。もう少し後ろに行くとチラと書いてあります。
SOCK_RAW sockets provide access to internal network protocols and interfaces. The type SOCK_RAW, which is available only to the super-user.
「SOCK_RAW ソケットは内部ネットワークプロトコルやインタフェースへのアクセスを提供します。SOCK_RAW タイプのソケットは管理者だけに有効です。」とありますね。これが RAW すなわち「生」のソケットを提供するということです。生のソケットを扱うのはそれなりに危険を伴うので特権モードでしか動きません。
RAW には2つの意味がある
さあ、使うなら RAW ソケットです。例えば ICMP を用いた ping のプログラムなどは様々な言語での実装がネットにころがってます。traceroute も ICMP 使ってますので、RAW ソケットの出番です。ただここで喜んでて「あれ?」となります。ICMP は Ethernet 上のプロトコルであり、Ethernet そのものではありません2。
RAW と言ってるのは確かに TCP や UDP よりは下の層ですが、IP や ICMP を扱うようです。Ethernet はどうなってるのでしょうか。実は MacOS は socket システムコールでは RAW socket を使っても Ethernet フレームを直接触る入出力は出来ないのです。つまり RAW には
- IP や ICMP を扱う
- Ethernet を扱う
の2つの意味があるのでした。MacOS に限らず FreeBSD, NetBSD, OpenBSD 等の BSD 系 UNIX クローンには全て備わっていないようです3。
BPF (Berkley Packet Filter)
でもなんか方法あるはず、と思いますよね。だって Wireshark とかのネットワークを直接嗅ぐ系のコマンドがあります。Ethernet を IP 層に持ってこずに OS 経由で見ることができるはずです。それが BPF です。これ、ちょっと前までは Ethernet フレームを読む(受信)だけだったのが、いつの間にか Ethernet フレームを書く(送信)ことができるようになっていました。
ですので、BSD 系の OS を使うなら BPF でプログラムすれば良いということになります。ところがなんと今回の話は MacOS に関してはここでおしまいです。ラズパイとかの IoT な小箱で使いたいので Mac はしばらくさようならです。
Linux の socket システムコール
Linux は BSD 系の unix クローンとは別の流れを持っています。Linux のシステムコールも調べてみましょう。正攻法で man socket
するとこう出てきます。
NAME
socket - create an endpoint for communication
SYNOPSIS
#include <sys/types.h> /* See NOTES */
#include <sys/socket.h>
int socket(int domain, int type, int protocol);
DESCRIPTION
... snip...
Name Purpose Man page
AF_UNIX, AF_LOCAL Local communication unix(7)
AF_INET IPv4 Internet protocols ip(7)
AF_INET6 IPv6 Internet protocols ipv6(7)
AF_IPX IPX - Novell protocols
AF_NETLINK Kernel user interface device netlink(7)
AF_X25 ITU-T X.25 / ISO-8208 protocol x25(7)
AF_AX25 Amateur radio AX.25 protocol
AF_ATMPVC Access to raw ATM PVCs
AF_APPLETALK AppleTalk ddp(7)
AF_PACKET Low level packet interface packet(7)
AF_ALG Interface to kernel crypto API
この AF_PACKET(ないしは PF_PACKET)ってのが Ethernet フレームを直接叩く type protocol です。そうです。Linux は socket システムコールで Ethernet IF を直接読み書きできるのです。これを使えば Raspberry Pi でも Ethernet フレームを扱えるようになります。
MacOS での開発にするかラズパイにするか迷うところですが、最終的に Nerves のターゲットマシンにしたいですから、ここはラズパイでの検討を優先することにしました。
Elixir で socket システムコールを使う
脳がすっかり UNIX システムコール用になってます。改めて Elixir でのネットワーク通信の方法はと言うと、プロセス間のメッセージングでした。それも複数のプロセスで気持ちよくプログラミングするなら GenServer にはじまる一連の並行プログラミング手法です。Agent とか Task とかすっかりスワップアウトされてますね。Elixir では通信の部分をしっかり隠蔽してしまって、良く抽象化されたレベルのプログラミング環境を提供しています。
逆に生ネットワークプログラミングの面で言うと Elixir 自身はほぼ全く何も持っていません。ちょっとなにかしようとすると Erlang のお世話になるしかないです。例えば TCP や UDP でプログラミングしようとすると :gen_tcp, :gen_udp で Erlang のライブラリを呼び出す必要があります。私たちの大好きなソケットもそのようです。アルケミストのみなさん、Erlang の世界へようこそ。
Erlang の socket ライブラリ
Erlang には socket という名前のライブラリがあります。これは UNIX の socket システムコールをそのまま使う目的で、Erlang 関数でラップするのを目指しているのはほぼ間違いなさそうです。比較的新しくて OTP 22 から導入されたようです。
そこでもちろん「さあこれを使おう」となるところ、良くドキュメントを読んでみましょう。UNIX の socket そのままを持ってこようとしているので、ドキュメントもなかなかの量です。ただ見るべきところは最初です。
stream
や dgram
に混じって raw
ってのがありますね。でもこれしかないですね。嫌な予感がします。そうです、この raw
で作れるソケットは IP や ICMP 用です。ちょうど MacOS とかの BSD 系のシステムコールに相当します。
では、linux のシステムコールの Ethernet を取り扱う PF_PACKET に対応するのはあるのでしょうか。それはきっと packet
という名前になっていそうです。嫌な予感が的中です。そうなんです。Erlang の socket ライブラリは標準のままでは Ethernet フレームを扱えないということです。
Erlang の socket ライブラリを拡張する
ならば socket ライブラリを素朴に拡張すれば良いではないかという気がします。それをやってるのがこちらです。
socket ライブラリの拡張
先頭にEthernetブリッジのErlangプログラムがあります。2つのEthernet I/F を引数として、一方から来た Ethernet フレームを反対側に投げるというをやります。これをやるのに packet で socket 関数を呼んでいます。ここが標準のライブラリにないところです。シンプルですね。ちなみに、両方向の伝送処理をそれぞれ並行プロセスにしてます。Elixir になれてるとあまり驚きませんが、このあたりが綺麗に書けるのも Erlang/OTP の良さですね。
さて、この Erlang のプログラムの後ろにはパッチコードがあります。これは ERTS (Erlang Run Time System) に対するパッチです。これも最初の方を見ると packet が選べるようになってることが分かります。このパッチを当てれば望みの socket ライブラリができるのでしょうが、チラ見した感じでは現行バージョンの Erlang/OTP には当たらないように見えます。パッチの量もなかなかのものです。Erlang と Unix socket に精通してないと触れなさそうです。
Erlang gen_socket
上の作者の shun159 さんに教えてもらったのがこちらです。ドキュメントや例が少なくてお試しするのがチト辛い。せっかく教えてもらいはしたのですが、斜め読みしただけで終わりました。
https://github.com/travelping/gen_socket/blob/master/src/gen_socket.erl
Erlang Procket
さて、これまでの話の私の結論として今のところ一番使えそうなのがこちらの procket です。これの README.md には明示的に
- generate and snoop packets using PF_PACKET sockets on Linux
- generate and snoop packets using the BPF interface on BSDs like Mac OS X
と書いてあります。ドキュメントや例も多いので一番使いやすそうに思えました。
では早速 procket を使ってみましょう。例によって mix new
してプロジェクトを作成してください。procket を使うのには mix.exs
に以下の依存関係を記述してください。
defp deps do
[
{:procket, github: "msantos/procket"}
]
end
これを書いたら mix deps.get
して、後は iex -S mix
でとりあえず使えます。簡単なもんです。4
Procket で特権モード動作の準備をする
これで socket の TCP や UDP は procket で使えるようになりました。ただし RAW ソケット使うならもう一声あります。前に raw ソケットを使う場合は特権モードが必要と書きました。ですので procket でも IP, ICMP, Ethernet を使う場合には、特権モードでシステムコールを呼び出すようにしておかないとなりません。
素の Elixir で(procket はなしで)やってみましょう。
$ iex # ユーザモードで実行する
Erlang/OTP 22 [erts-10.5.3] [source] [64-bit] [smp:4:4] [ds:4:4:10] [async-threads:1] [hipe] [dtrace]
Interactive Elixir (1.9.2) - press Ctrl+C to exit (type h() ENTER for help)
iex(1)> {:ok, socket} = :socket.open(:inet, :raw, :icmp)
** (MatchError) no match of right hand side value: {:error, :eperm}
(stdlib) erl_eval.erl:453: :erl_eval.expr/5
(iex) lib/iex/evaluator.ex:257: IEx.Evaluator.handle_eval/5
(iex) lib/iex/evaluator.ex:237: IEx.Evaluator.do_eval/3
(iex) lib/iex/evaluator.ex:215: IEx.Evaluator.eval/3
(iex) lib/iex/evaluator.ex:103: IEx.Evaluator.loop/1
(iex) lib/iex/evaluator.ex:27: IEx.Evaluator.init/4
iex(1)>
なんか怒られちゃいました。例によってエラーメッセージが親切ではないです。これ特権モードで動かすとこうなります。
$ sudo iex # 特権モードで動かす
Password:
Erlang/OTP 22 [erts-10.5.3] [source] [64-bit] [smp:4:4] [ds:4:4:10] [async-threads:1] [hipe] [dtrace]
Interactive Elixir (1.9.2) - press Ctrl+C to exit (type h() ENTER for help)
iex(1)> {:ok, socket} = :socket.open(:inet, :raw, :icmp)
{:ok, {:socket, #Reference<0.2568000987.817233924.138281>}}
iex(2)>
と、ちゃんとソケットが出来ました。これは RAW ソケットと言っても ICMP ですが、それでも特権モードが必要です。いわんや生Ethernetをや。
そこで、プロケットではいくつかの方法で特権モードを扱います。ドキュメントには /etc/sudoers に記述する方法が最初にありますが、こんな簡単なことがどうも私の環境ではうまく行かなかったので、以下の「とあるファイルにのみ管理者実行権限を与える」方法をとりました。
$ sudo chown root deps/procket/priv/procket
$ sudo chmod u+s deps/procket/priv/procket
$ ls -l deps/procket/priv/procket
-rwsr-xr-x 1 root staff 16460 11 29 09:09 deps/procket/priv/procket
Procket の Erlang 版エコーバックプログラムを Elixir 用に改造する
これで procket が raw ソケットを扱えるようになります。iex を起動するときにも sudo する必要はありません。procket にはいくつかの Erlang によるサンプルプログラムが掲載してあります。先頭が echo.erl というソケットを用いたエコーバックプログラムがありましたので、まずはこれを比較的そのまま Elixir プログラムにしてみました。
require Logger
defmodule Echo do
@port 54
def start() do
start(:tcp)
end
def start(:tcp) do
start(@port, [{:protocol, :tcp}, {:family, :inet}, {:type, :stream}])
end
def start(:udp) do
start(@port, [{:protocol, :udp}, {:family, :inet}, {:type, :dgram}])
end
def start(port, options) do
proto = :proplists.get_value(:protocol, options, :tcp)
family = :proplists.get_value(:family, options, :inet)
{:ok, fd} = :procket.open(port, options)
IO.puts("Listening on: #{port}, #{proto}")
listen(proto, family, fd)
end
def listen(:tcp, family, fd) do
{:ok, s} = :gen_tcp.listen(0, [:binary, family, {:fd, fd}])
accept(s)
end
def listen(:udp, family, fd) do
{:ok, _s} = :gen_udp.open(0, [:binary, family, {:fd, fd}])
recv()
end
def accept(ls) do
{:ok, _s} = :gen_tcp.accept(ls)
spawn(fn() -> accept(ls) end)
recv()
end
def recv() do
receive do
{:tcp, s, data} ->
# :gen_tcp.send(s, data) # そのままエコーバックする場合
:gen_tcp.send(s, "! #{String.upcase(data)}") # ちょっと加工
recv()
{:tcp_closed, s} ->
:gen_tcp.close(s)
{:udp, s, ip, port, data} ->
:gen_udp.send(s, ip, port, data)
recv()
_ -> Logger.error("recv")
end
end
end
これを実行してみます。
$ iex -S mix
Erlang/OTP 22 [erts-10.5.3] [source] [64-bit] [smp:4:4] [ds:4:4:10] [async-threads:1] [hipe] [dtrace]
Interactive Elixir (1.9.2) - press Ctrl+C to exit (type h() ENTER for help)
iex(1)> Echo.start
Listening on: 54, tcp
と出てくるので別のターミナルで以下をやります。
$ telnet localhost 54 # TCP ポート54 に接続
Trying 127.0.0.1...
Connected to localhost.
Escape character is '^]'.
hello
! HELLO
world
! WORLD
hello
と world
が2回ずつ出てきます。これそれぞれ最初のが自分で打った入力で、続いて大文字になって出てくるのが Echo.start/0
関数がエコーバックしてきた結果です。セッションを終了するには telnet したターミナルでコントロールキーを押しながら ]
を打ちます。
^]
telnet> quit
Connection closed.
$
すると Elixir 側でも
:ok
iex(2)>
と tcp セッションをクローズして終了します。
まとめ
- Ethernet のフレームを扱うには BSD OS の BPF か linux の socket(2) を使う
- Elixir で Ethernet フレームを扱うのに Erlang のライブラリを使った
- Erlang の procket ライブラリが素性が良さそうで試してみた
え? 結局 RAW ソケットでプログラムはしてないじゃないかって? そこまで行かなかったんです。続きは乞うご期待。
明日のElixir Advent Calendar 2019 の記事は, @niku さんのescriptを動かすDockerfileのサンプルです。こちらもお楽しみに!
謝辞
これをやるに当たりたくさんの方にお世話になりました。
特にサッポロビームの @niku さんには erlang の外部ライブラリを Elixir で動かすのにガッツリお付き合い頂きました。大変助かりました。ありがとうございました。
また、Elixir.jp slack や Erlang & Elixir Fest のみなさんにお世話になりなりました。taiyo さん、seizans さん、Kinukawa Ryota さん、jj1bdx さん、(以上 slack 名)。ご助言ありがとうございました。
また、これをやるにあたり自分の時間の確保にはもくもく会を利用させてもらいました。fukuoka.ex kokura.exでもくもく会を準備してくれたみなさんと一緒にもくもくしてくれたみなさんに感謝いたします。
参考文献
- UNIXネットワークプログラミング 第2版 Vol.1, W.Richard Stevens著, 篠田陽一訳、ISBN4-8101-8612-1 (ソケットプログラミングのバイブルと思います。絶版のようです)
- Procket
- Erlang Socket
- Ruby class Socket
- Python socket
-
生Ethernetフレームが扱えるかは、該当する言語の socket ライブラリで socket オプションに PF_PACKET や AF_PACKET が使えるかを調べることでざっくり分かります。 ↩
-
正確にはIPのプロトコル番号1として規定されています。[INTERNET CONTROL MESSAGE PROTOCOL, RFC792] (https://tools.ietf.org/html/rfc792) ↩
-
ごめんなさい。ここちゃんと調べきれてません。そんなことねーよ、という情報があればぜひ教えて下さい。 ↩
-
と、ここさらりと書いていますが、使おうと思ってからここに至るのに約10時間ほど煮溶かしてます。 ↩