Ubuntu Weekly Recipe

第497回トランプ大統領のツイートをbiffで通知する

先週アメリカ合衆国のトランプ大統領が来日しましたね。そこで今回はトランプ大統領のツイートを「biff」で通知してみましょう。

そもそも「biff」とは

「biff」とはBSD由来のメール通知システムです。通知を受け取るかどうかを設定するbiffコマンドと実際に通知を発行するcomsatデーモンが協調して、以下のような流れでユーザーにメールが届いたことを通知します。

  1. comsatデーモンは定期的に/var/run/utmpからログインユーザーのリストを取得しておく
  2. MTAがメールを受け取る
  3. MTAがcomsatデーモンに「受信者」⁠メールボックスファイル名」⁠受信したメールのファイル上のオフセット」をUDPで通知する
  4. comsatデーモンは「受信者」がログインしているか確認する
  5. comsatデーモンは受信者が使用している端末ファイル/dev/ttyX/dev/pts/Xのbiffビットが立っているか確認する
  6. biffビットが立っている端末ファイルに対して、メールの「From:」「Subject:⁠⁠、さらに本文の一部を書き込む
  7. 先頭7行もしくは560文字の小さいサイズに到達した時点で書き込みを終了する

要するに「エディターを開いていようが何していようが、問答無用で(仮想)端末デバイス上にメール着信通知を行うシステムです。つまり古いUnix環境であれば便利だったかもしれないツールではあるものの、現在の環境ではほぼ役に立たないというかむしろ邪魔になるツールです。そのため現在ではまず使われていませんし、512番ポートが開いていることも稀でしょう[1]⁠。

しかしながら「メールを受信したらユーザーに通知する」というアイデアそのものは今でも有用です。xbiffやgnubiffといった「ユーザーの邪魔をしないような」単体の通知システムが実装されましたし、今ではメールクライアントそのものにも通知システムが搭載されていることが一般的です。

ところでこのオリジナルのbiffコマンドとcomsatデーモンそのものはパッケージリポジトリに残っています。そこで今回はあえてこのbiffコマンドを使うことで、昔の環境ではどのように「You've Got Mail」していたのか体験してみることにしましょう。

正直に言うと「トランプ大統領とbiff」という話をやりたかっただけです[2]⁠。

biffのインストール

biffとcomsatデーモンはともにbiffパッケージに含まれています。よってインストールは簡単です。

$ sudo apt install biff
(中略)
biff (1:0.17.pre20000412-5) を設定しています ...
grep: /etc/inetd.conf: そのようなファイルやディレクトリはありません
grep: /etc/inetd.conf: そのようなファイルやディレクトリはありません

最後にエラーが出ていますが、comsatはinetd経由で起動するサービスであるにも関わらず最近のシステムだとinetdがインストールされていないことが原因です。/etc/inetd.confの中身を確認した上でupdate-inetdコマンドを実行しようとするものの、/etc/inetd.confが存在しないために上記のようなgrepコマンドのエラーが出ています。本来はbiffパッケージがxinetdなどに依存しておくべきですが、今回はinetdを使わないためそのままにしておきます。

inetdとsystemd

そう、inetdです。comstatは/etc/inetd.confに以下の項目を記述することでサービスを有効化します。

biff    dgram   udp wait    root.tty    usr/sbin/in.comsat  comsat

/etc/servicesファイルを確認するとbiffサービスについて記載されていることがわかります。

$ grep biff /etc/services
biff            512/udp         comsat

inetd経由で起動するサービスの特徴のひとつが、⁠起動したサービスの標準入出力がクライアント側のソケットの入出力に繋がっている」ことです。つまりサービスプロセスが標準入力から(0番のファイルディスクリプターから)読み込むと、それはクライアントから送られてきたデータを読み込むことになりますし、標準出力に書き出したデータはネットワーク経由でクライアントに送られることになります。実際にcomsatのソースコードには、以下のようなコードがあります。

cc = recv(0, msgbuf, sizeof(msgbuf) - 1, 0);
if (cc <= 0) {
    if (errno != EINTR) sleep(1);
    continue;
}

このためinetd用のサービスをそのまま実行してもうまく動きません。comsatデーモンを動かすためにはinetdサービスが必要になります。ここで素直にxinetdをインストールしてもいいのですが、実はsystemdはinetdのようなsocketドリブンのサービスを作ることも可能なのです。今回はみんな大好きsystemdを使って実現してみましょう。

まずsocket unitを作成します。/etc/systemd/system/biff.sockを次の内容で作成します。

[Unit]
Description=Biff Comsat

[Socket]
ListenDatagram=[::1]:512

[Install]
WantedBy=sockets.target

内容はごくごくシンプルで、⁠localhostの512ポートで待ち受けるsocket unit」です。ListenDatagramでデータグラムのsocketにしています。ストリームにしたい場合は、ListenStreamにしてください。またその場合は、Acceptも設定したほうがいいでしょう。

ListenDatagramのアドレス部分には[::1]:512を指定しています。ここは次のようなルールになっています[3]⁠。

  • 「/」で始まる場合はUnixドメインソケットのパスとして使われます
  • 「@」で始まる場合はLinuxの抽象ソケットアドレスとして扱います
  • 単一の数字の場合はポート番号として扱い、システム上のすべてのインターフェースをlistenします
  • 「A.B.C.D:PORT」の場合はIPv4アドレスとポート番号として指定したアドレスでlistenします
  • IPv6表記の場合はIPv6でlistenしますが、設定によってはIPv4アドレスも同時にlistenします

上記の例ではIPv6表記のループバックアドレスを指定しているため、ローカルからIPv6と設定次第ではIPv4でもアクセスできることになります。

次にこのsocket unitにパケットが届いたときにアクティベートする本体のサービスファイルを/etc/systemd/system/biff.serviceとして作成します。

[Unit]
Description=Biff Service
Requires=biff.socket

[Service]
Type=simple
ExecStart=/usr/sbin/in.comsat
StandardInput=socket
StandardError=journal
TimeoutStopSec=5

[Install]
WantedBy=multi-user.target

見た目はごくごく普通のサービスファイルですね。comsatデーモン自身はforkしないのでサービスタイプはType=simpleです。またRequiresには先程作成したbiff.socketを指定しています。

inetdライクなサービスにするためにStandardInput=socketを設定しています。これによりinetdと同じく標準入力がsocketにつながることになります。ちなみにcomsatデーモンは何もデータを返しません(標準出力に対してsend()write()を呼びません⁠⁠。よってStandardOutputは設定していません。一般的なinetdサービスであれば、StandardOutputも設定する必要があるでしょう。

biff.serviceはbiff.socketによってアクティベートされるので、biff.socketのみ起動しておきましょう。

$ sudo systemctl daemon-reload
$ sudo systemctl start biff.socket

これで512番ポートにパケットが届くとcomsatが起動するようになりました。

biffのテスト

実際にbiffパケットをcomsatデーモンに送って試してみましょう。biffのパケットは次のようなフォーマットになっています[4]⁠。

送信先ユーザー名@ファイルオフセット:ファイル名

「ユーザー名」は送信対象のユーザー名です。comsatはこのユーザー名が使っている端末ファイルのうち、biffビットがセットされているファイルに対してメッセージを送ります。

「ファイルオフセット」「ファイル名」が実際のメッセージの位置です。ファイル名は/usr/include/paths.h_PATH_MAILDIR以下のファイルパスになります。Ubuntuの場合は/var/mailが設定されていますので/var/mail/ファイル名と解釈されます。

/var/mail/ファイル名はいわゆるmbox形式のメールアーカイブファイルであり、1つのファイルに延々とメールデータを記録していくフォーマットです。Ubuntuだとたとえばmailutilsパッケージのmailコマンドで表示できます。一般的には「From 送信元 日時」の行から始まり、何行かのヘッダーフィールド、空行、本文、空行までで1メッセージとなります。

とりあえず適当なメールボックスとメッセージを作成しておきましょう。

$ echo -e "From test\n\nline1\nline2\nline3\nline4\nline5\n" | sudo tee /var/mail/$USER
From test

line1
line2
line3
line4
line5

$ sudo chown $USER: /var/mail/$USER

端末ファイルの「biffビット」は、とどのつまりファイルオーナーの実行可能ビットです。biffコマンドでオン・オフできますが、chmodで代用してもかまいません。とりあえず手元の端末でオンにしてみましょう。デスクトップ環境の端末エミュレーター上のような仮想端末でも動作します。

$ biff y
$ ls -l `tty`
crwx-w---- 1 shibata tty 136, 33 11月 12 09:39 /dev/pts/33

これでcomsatデーモンがこの端末上にメッセージを表示できるようになりました。では、デーモンにパケットを送ってみましょう。bashであれば/dev/udp/アドレス/ポートでUDP通信を行えます。

$ echo -n "shibata@0:shibata" > /dev/udp/::1/512

New mail for shibata@ubuntu-desktop has arrived:
----
line1
line2
line3
line4
line5
----

無事にオフセット0、つまりファイルの先頭にあるメッセージを表示できることがわかりましたね。このメッセージはbiff yを実行した端末すべてに表示されます。表示を無効化する場合はbiff nです。

特定のユーザーのツイートを取得するスクリプト

仕組みさえわかってしまえば、どう活用するかはあなたのアイデア次第です。メールのように外部からpushされる通知のうち、作業を中断してまで受け取りたい通知と言えば、そうTwitterです[5]⁠。

Twitterの情報を取得する方法はいろいろありますが、今回はPythonのTwitterライブラリーであるTwythonを使うことにしましょう。なお今回紹介するPythonスクリプトは3.6が前提になっています。これはmbox用のメッセージを構築するために、3.6で追加されたemail.messageを使用しているためです。よってUbuntu 17.04であればpython3.6パッケージ、Ubuntu 16.04以前であれば別途Python 3.6をインストールする必要があることに注意してください。

$ sudo apt install python3-twython

ただしemail.messageでないといけないというわけではなく、メッセージ中身はただのプレーンテキストなので、手で構築するように改修すればPython 3.6より前のバージョンでも動きます。

あらかじめapps.twitter.comでConsumer Key/Consumer Secret/Access Token/Access Token Secretの4つのキーを生成しておいてください。今回は特にTwitterに書き込むことはしませんので、パーミッションはread-onlyで十分です。Stream APIを使うためUser authentication(OAuth 1.0a)を使う必要があります。そこであらかじめAccess Tokenも取得しているわけです。

今回使うPythonスクリプトは次のとおりです。

#!/usr/bin/env python3

from twython import Twython, TwythonStreamer
import time
import sys
from email.message import EmailMessage
import mailbox
import socket

APP_KEY = 'Consumer Key'
APP_SECRET = 'Consumer Secret'
OAUTH_TOKEN = 'Access Token'
OAUTH_TOKEN_SECRET = 'Access Token Secret'

USERS=['realdonaldtrump']
SEND_TO = 'shibata'
HOST = '::1'
PORT = 512

class TwitterStreamer(TwythonStreamer):
    def __init__(self, *args, **kwargs):
        self.follow = kwargs.pop('follow')
        super(TwitterStreamer, self).__init__(*args, **kwargs)

    def on_success(self, data):
        try:
            if data['user']['id_str'] in self.follow:
                offset = self.save_mbox(data['user']['screen_name'], data['text'])
                self.send_biff(SEND_TO, offset)
        except KeyError:
            pass

    def on_error(self, status_code, data):
        sys.stderr.write("failed connect to stream: {}\n".format(status_code))
        self.disconnect()
        sys.exit(1)

    def save_mbox(self, user, text):
        msg = EmailMessage()
        from_line = 'From Twitter ' + time.asctime(time.gmtime())
        msg.set_unixfrom(from_line)
        msg.set_content(text)
        msg['From'] = user

        mb = mailbox.mbox(SEND_TO, create=True)
        mb.lock()
        key = mb.add(msg)
        mb.flush()
        mbf = mb.get_file(key)
        offset = mbf._start
        mb.unlock()
        mb.close()

        return offset

    def send_biff(self, user, offset):
        msg = "{user}@{offset}:{file}".format(user=user, offset=offset, file=user)
        addr = socket.getaddrinfo(HOST, PORT, proto=socket.IPPROTO_UDP)
        s = None
        for res in socket.getaddrinfo(HOST, PORT, socket.AF_UNSPEC, socket.SOCK_DGRAM):
            af, socktype, proto, canonname, sa = res
            try:
                s = socket.socket(af, socktype, proto)
            except OSError as msg:
                s = None
                continue
            try:
                s.connect(sa)
            except OSError as msg:
                s.close()
                s = None
                continue
            break
        if s is None:
            return
        with s:
            s.sendall(msg.encode('utf-8'))

twitter = Twython(APP_KEY, APP_SECRET, OAUTH_TOKEN, OAUTH_TOKEN_SECRET)
users = twitter.lookup_user(screen_name=USERS)
follow = [ x['id_str'] for x in users ]

stream = TwitterStreamer(APP_KEY, APP_SECRET, OAUTH_TOKEN, OAUTH_TOKEN_SECRET, follow=follow)
stream.statuses.filter(follow=follow)

USERSはツイートを取得したいユーザーのscreen_nameのリストです。SEND_TOが送信対象のユーザー名になります。HOSTがcomsatデーモンのアドレス(今回はIPv6のループバックアドレス⁠⁠、PORTがそのポート番号です。

TwythonTwythonStreamerについてはTwythonのドキュメントを見てもらうほうがはやいでしょう。ほぼチュートリアルどおりの構成です。引数にfollowを追加しているのは、Public StreamをユーザーIDでフィルタリングした場合、mentionにそのユーザーIDが含まれているツイートも引っかかってしまうからです。そこでon_success()で送信者がfollowにリストアップしたユーザーのときのみcomsatに通知するようにしています。

save_mbox()send_biff()がbiff固有の処理です。save_mbox()ではemail.message.EmailMessageで電子メールのメッセージを作成しています。最初の行に「From Twitter 日付」形式のメッセージを記録し、ツイートの中身をBodyとしています。From:ヘッダーにはツイートした人のscreen_nameを残しています。

def save_mbox(self, user, text):
    msg = EmailMessage()
    from_line = 'From Twitter ' + time.asctime(time.gmtime())
    msg.set_unixfrom(from_line)
    msg.set_content(text)
    msg['From'] = user

    mb = mailbox.mbox(SEND_TO, create=True)
    mb.lock()
    key = mb.add(msg)
    mb.flush()
    mbf = mb.get_file(key)
    offset = mbf._start
    mb.unlock()
    mb.close()

    return offset

mbox形式のファイルはmailboxライブラリーmailbox.mboxで操作できます。add()メソッドでメッセージを簡単に追加できるのですが、追加したメッセージのファイル先頭からのオフセット位置を簡単に知るすべはありません。mailboxにはは指定したメッセージ部分のみをファイルオブジェクトとして取得できるget_file()メソッドが存在しますので、このオブジェクトの_startプロパティで代用しています。アンドキュメンテッドなプロパティを使いたくない場合は、from_lineで検索して算出してもいいかもしれません。

保存先SEND_TOは相対パスになっています。このまま実行するとカレントディレクトリに保存されることになるので注意してください。カレントディレクトリの位置はのちほどsystemd側で調整する予定です。ただしこのスクリプトはbiffでしか使わないことを考えると、/var/mailまでスクリプトの中で含めてしまうという方法もあるでしょう。

send_biff()の方はごく普通のネットワークプログラミングです。以下のフォーマットのメッセージを送っています。

msg = "{user}@{offset}:{file}".format(user=user, offset=offset, file=user)

サービスファイルの作成

先ほど作成したスクリプトもsystemdから起動するように/etc/systemd/system/trump.serviceを作成しておきましょう。

[Unit]
Description=Trump Logger
After=network-online.target
Wants=network-online.target

[Service]
Type=simple
User=shibata
Group=mail
WorkingDirectory=/var/mail
ExecStart=/usr/local/bin/trump.py

[Install]
WantedBy=multi-user.target

network-online.targetを使うことで、ネットワーク接続が有効化されたときにサービスが起動することになります。これはNetworkMangerでもsystemd-networkdでも有効です。サービスを有効化すると、ツイートの取得を開始します。

$ sudo systemctl start trump.service

あとはbiff yした端末の前で靴紐がほどけているか確認しながら待っていると、トランプ大統領のツイートが流れてくるはずです。

ちなみにcomsatは渡された文字列に対してtoascii()で最上位ビットを落とそうとします。つまり日本語や絵文字はまったく表示できません。ただemail.message.EmailMessageの時点でBase64エンコードするため、表示された文字列をbase64コマンドでデコードすれば日本語や絵文字に戻すことは可能です。

おすすめ記事

記事・ニュース一覧