Twitter bot を作った時のメモ

Twitter bot を作ってみているのでその時のメモ。 あくまで個人的なメモであり、網羅的な解説などをするものではないです。

方針

なるべく新しいものを使いたいと思ったので、Twitter API v2 + OAuth 2.0 を使うことにした。

Twitter API v2 は 2022-11-15 にプライマリ API に指定された。まだ一部 1.1 にあって 2.0 にはない API もあるようだが、基本的には 2.0 を使っていくのがよさそうだ。

OAuth はどうも、App レベルのアクセスは 2.0 でユーザーレベルのアクセスは 1.0a みたいに使い分けることもできるらしい。 が、調べながらやった結果、その辺りをちゃんと把握せずに進めてしまい、今回は 2.0 だけでやるようにしてしまったので一旦その方向で進めている。

bot 用のアカウントを作る

bot 用に新しく Twitter アカウントを作る。 未ログインの状態で Twitter トップページから通常の手順で作れば問題ない。

色々挙動を確認したりするのに便利なので、運用に使うアカウントとは別にデバッグ用のアカウントを用意すると便利だ。一度 bot の運用を開始すると下手なテストができなくなる。

アカウントに電話番号を登録する

Twitter API を使うためには Twitter アカウントを開発者アカウントに登録する必要があるが、そのためには Twitter アカウントに電話番号を登録する必要がある。同じ電話番号を複数のアカウントに登録しても問題ないようなので、複数の bot を作る場合でも電話番号は 1 つで問題なさそうである。

一般的な手順通り、Twitter のログイン時の画面の左メニューから「もっと見る」「設定とプライバシー」「アカウント」「アカウント情報」でパスワードの確認が入るのでパスワードを入力し、「電話」「電話番号を追加」で行う。 番号を入力すると SMS で認証コードが送られてくるので入力すると登録できる。

なお、下記手順で開発者アカウントを有効にした後に試しにデバッグ用のアカウントに登録した電話番号の登録を消してみたが、今のところは問題なく API は使えている。もしかしたら何かのタイミングで使えなくなる可能性はあるかもしれない。

開発者アカウントを有効にする

Twitter アカウントを開発者アカウントに登録する。

開発者アカウントに登録したい Twitter アカウントにログインした状態で https://developer.twitter.com/ にアクセスし、右上の Sign up ボタンを押す。

名前、国、利用目的(選択式)、政府が使うものかどうか(?)、更新情報をメールで受け取るかを選んで下部の Next を押し、次の画面で Developer agreement & policy に同意するチェックボックスにチェックを入れて Submit を押す。

最後にメールアドレスの検証があるので、登録したメールアドレスに届いた検証メールを探して Confirm your email を押せば API の利用を開始できる。

開発者アカウントにはアクセスレベルがあり、一番簡単な(制限が強い) Essential access であればこれだけで使える。以前は申請理由について英作文をする必要があったようだが、今はここまでなら必要はない。

App を登録する

検証メールから Confirm your email を押すと、App の名前を入力するページに飛ばされる。 アプリ名はグローバルでユニークである必要があるが、後から変更できるので適当な名前でもよい。日本語でも大丈夫。 名前は OAuth の認可画面やツイート時の source 情報に使われる模様。

アプリ名を入力すると「API Key」「API Key Secret」「Bearer Token」が得られる。なくさないように保管する。

API を呼び出してみる

Bearer Token を使うと、ユーザーに紐付かない API を呼び出すことができる。

❯ curl -s -H "Authorization: bearer ${TWITTER_APP_BEARER_TOKEN}" 'https://api.twitter.com/2/users/by?usernames=thinca'
{"data":[{"id":"15676452","name":"thinca","username":"thinca"}]}

OAuth 2.0 を有効にする

API 経由で bot アカウントでツイート等を行うには、OAuth を使ってアカウントから App に認可を与える。 Developer Portal の左のメニューの Projects & Apps からプロジェクト内の Apps を選び、「User authentication settings」から「Set up」を押す。

OAuth 2.0 を有効にして、アプリのタイプを選ぶ。今回は bot を作るので「Automated App or bot」を選ぶ。

「Callback URI / Redirect URL」と「Website URL」を設定する。 これは一般ユーザー向けに認可を行いたい場合はきちんと Web アプリを用意する必要があるが、今回は bot アカウントだけでよいので手動でがんばればなんとかなる。 Callback URI には適当に http://localhost/ を設定(この URL に token 等が渡るので外部の URL にしてはいけない)。Website URL には http://example.com/ などを適当に設定する。

すると「Client ID」と「Client Secret」が手に入るのでこれをなくさないように保管する。

OAuth 2.0 で bot に権限を与える

認可用の URL を作る。

  • type = code 固定
  • client_id = 先ほど生成した Client ID
  • redirect_uri = 先ほど Callback URI / Redirect URL に登録したもの
  • scope = 許可を与えるスコープのスペース(%20)区切りのリスト
    • API ごとに、その API を利用するのに必要なスコープが決められている
  • code_challenge = ちゃんとやる場合はランダムに生成する必要があるが、今回は固定で challenge
  • code_challenge_method = ちゃんとやる場合は S256 とかを指定するが、今回は plain
https://twitter.com/i/oauth2/authorize?response_type=code&client_id=${CLIENT_ID}&redirect_uri=http://localhost/&scope=tweet.read%20users.read%20tweet.write&state=state&code_challenge=challenge&code_challenge_method=plain

bot のアカウントでログインした状態で作った URL にアクセスすると、認可のための画面になる。 「アプリにアクセスを許可」を押すと、Redirect URI に指定した URI にパラメータ付きでリダイレクトされる。 本来はここで Web アプリがリクエストを受け取り、サーバで処理を行う。 が、今回はともかく bot の認可だけできればいいので、リダイレクトされた先(localhost だと当然サーバが動いていなければ接続エラーになるが、URL さえわかればよいので問題はない)の URL の code= の部分に現れたコードを使って以下のように HTTP リクエストを手動で発行する。

${CLIENT_ID}${CLIENT_SECRET} にはそれぞれ Client ID と Client Secret を、${CODE} にはリダイレクトされた URL から得たコードを入れる。code_verifier にはちゃんとやる場合は計算して出した verifier を入れる必要があるが、今回は plain で値を固定しているのでそのまま先ほどの challenge を入れる。 なお、この ${CODE} の寿命はとても短いので、今回のように手動で行う場合は急いで行う必要がある。ちゃんと計測していないが数分で切れているように思う。

curl --location --request POST 'https://api.twitter.com/2/oauth2/token' \
--header 'Content-Type: application/x-www-form-urlencoded' \
--basic -u "${CLIENT_ID}:${CLIENT_SECRET}" \
--data-urlencode "code=${CODE}" \
--data-urlencode 'grant_type=authorization_code' \
--data-urlencode "client_id=${CLIENT_ID}" \
--data-urlencode 'redirect_uri=http://localhost/' \
--data-urlencode 'code_verifier=challenge'

ちなみにここで Basic 認証をしているが、OAuth 2.0 を有効にした際に選んだアプリのタイプ(今回は Automated App or bot を選んでいる)によっては不要になるそうだが、今回は試していない。Confidential Client に分類されるタイプの場合は必要で、Public Client に分類されるタイプは不要らしい。

すると以下のように Access Token が得られる。Access Token は寿命が短く、7200 秒(2 時間)で切れる。

{"token_type":"bearer","expires_in":7200,"access_token":"dEVDdWkxQnVZT184RlhZM0RQdHp6SjA3S2ExQmVQWG5KOTNvWkdmdGdueTh5OjE2NDE4MjEwOTYwNDM6MTowOmF0OjE","scope":"tweet.write users.read tweet.read"}

この Access Token を以下のように Authorization ヘッダに渡すことでユーザーとして API を利用できる。 以下は認証しているユーザー自身の情報を返す API を利用する例。

curl -H 'Authorization: bearer dEVDdWkxQnVZT184RlhZM0RQdHp6SjA3S2ExQmVQWG5KOTNvWkdmdGdueTh5OjE2NDE4MjEwOTYwNDM6MTowOmF0OjE' https://api.twitter.com/2/users/me

Refresh Token を使って永続的にアカウントにアクセスする

認可用の URL を作る際に、scope に offline.access を入れておくと、Access Token 発行の際に同時に Refresh Token も発行される。 Refresh Token を使うと Access Token を再発行できる。これを使うことで再度ユーザーに認可画面を表示することなくアクセスが可能になる。

https://twitter.com/i/oauth2/authorize?response_type=code&client_id=${CLIENT_ID}&redirect_uri=http://localhost/&scope=tweet.read%20users.read%20tweet.write%20offline.access&state=state&code_challenge=challenge&code_challenge_method=plain

上記のように offline.access を足した画面から発行した code を先ほどと同様に https://api.twitter.com/2/oauth2/token に渡すと、以下のように Refresh Token も一緒に返ってくる。

{"token_type":"bearer","expires_in":7200,"access_token":"UEo2TDBIemlBUkdsVlRJNHpzMWxZekFkd24tcGFETGFmVXJOWEtKdXJJVUU0OjE2NDE4MjE3NTA4ODA6MToxOmF0OjE","scope":"tweet.write users.read tweet.read offline.access","refresh_token":"b0F1bXNtd0Q4QlpDSGg5dWVEeG82UHNIY3lGcC1RbVRUMzRZcVQ0bFpkSVhrOjE2NDE4MjE3NTA4ODA6MToxOnJ0OjE"}

Refresh Token を以下のように使えば、新しい Access Token と Refresh Token が手に入る。

curl --location --request POST 'https://api.twitter.com/2/oauth2/token' \
--header 'Content-Type: application/x-www-form-urlencoded' \
--basic -u "${CLIENT_ID}:${CLIENT_SECRET}" \
--data-urlencode 'refresh_token=b0F1bXNtd0Q4QlpDSGg5dWVEeG82UHNIY3lGcC1RbVRUMzRZcVQ0bFpkSVhrOjE2NDE4MjE3NTA4ODA6MToxOnJ0OjE \
--data-urlencode 'grant_type=refresh_token' \
--data-urlencode 'client_id=${CLIENT_ID}'
{"token_type":"bearer","expires_in":7200,"access_token":"aTl5MVNPMlhHSUltck5lOWFlM2lyQ3dLbGhMWnBEbUxEdl9Zdi1yWGdPMUtCOjE2NDE4MjgyMDg0ODU6MTowOmF0OjE","scope":"tweet.write users.read tweet.read offline.access","refresh_token":"TlZvNXNWT0wyMzhialRZdWloM0w5OENGc3JnZzVDYlphLTM3OXItMzVaUXgwOjE2NDE4MjgyMDg0ODU6MToxOnJ0OjE"}

新しい Access Token と Refresh Token が発行されると、古い Access Token と Refresh Token はどちらも無効になる。 実際の運用時には Access Token とその有効期限と Refresh Token を保存しておき、有効期限が切れていたら Access Token を再発行して使うことになる。

Optional: アクセスレベルを上げる

開発者アカウントに登録した直後は Essential Access になっている。申請を行うことで、Elevated Access にすることができる。 検索やユーザータイムライン取得などのいくつかの API は月当たりに取得できるツイート数の上限が決まっている。Essential は 50 万だが、Elevated は 200 万になる。また、作れるアプリの数や Filtered Stream に設定できるルールの数などいくつかの点で違いがある。

ログインした状態で以下にアクセスし、「Apply for Elevated」を押す。

https://developer.twitter.com/en/portal/products/elevated

Twitter アカウント、メールアドレス、個人開発者かどうか、名前、国、コーディングスキル(承認の可否には影響しない)、更新情報をメールで受け取るかを選んで下部の「Next」を押す。

次の画面で、申請したい理由を英語で書く必要がある。最低 200 文字。用途に応じて追加でいくつか理由を書く必要がある。ここは必要であれば機械翻訳等を駆使しつつ素直に書くのがよさそう。 私の場合は具体的に作りたい bot が決まっていたので、そのために制約の緩和があると助かる、みたいなことを書いたら 1 時間かかるかどうかの速度であっという間に承認された。

具体的な API の利用について

別記事で書くかもしれないし書かないかもしれない。とりあえず現状だと、API v2 ではできないけど v1.1 ではできること、v1.1 ではできないけど v2 ではできること、どちらもある。今後の v2 のアップデートに期待したい。

qfhl.vim を作った

quickfix は便利ですが、それがバッファ内のどの位置を指しているのか、視覚的には分かりづらいです。

これを解決するための vim-hier というプラグインがあります。このプラグインは、quickfix の位置をハイライトで視覚的に表示してくれます。 実用には十分ではあるのですが、長らくメンテされておらず、またプラグインの構造やハイライトの仕組みが古いことが気になっていました。

一方、現在の Vim には text property という仕組みがあります。これを使えば、いい感じのものが作れそうな気がしました。

というわけで作ってみました。

github.com

(作ってみましたと軽く言ってますが、放置期間も含めると数年越しのリリースです…。)

qfhl.vim は、quickfix および location list の場所をバッファ上でハイライトします。

ハイライトには text property を使用しています。text property はバッファの一部に埋め込まれ、バッファ編集に合わせて位置が移動します。 これにより、ハイライト後にバッファを編集して行がずれたりした場合でも、ハイライトの位置も適切に移動します。

quickfix には行と桁と言う位置情報があり、それを元にハイライトを行っていますが、これだけだとどこまでハイライトをすればいいのかが判断できません。

これは不便なので、Vim 本体に PR を出しました。

github.com

quickfix が持つ位置情報に、オプショナルな終了位置の行と桁の情報を持たせられるようにする変更です。この PR は無事 v8.2.3019 で取り込まれました。

と言うわけで、もし quickfix が終了位置を持っていた場合は qfhl.vim も適切な範囲をハイライトします。 とは言うものの、quickfix を設定する多くの grep 系のコマンドやプラグインは、まだ終了位置を設定してくれないでしょう。終了位置がない場合は、仕方ないので行末までハイライトをします。

現時点で手軽に試すには :vimgrep コマンドが使えます。:vimgrep は組み込みコマンドだったので、上記の PR で終了位置を設定するように対応しています。

f:id:thinca:20210731135546p:plain
qfhl でハイライトを行った様子

最後に注意点ですが、Vim script の新しめの機能を使って書いているため、恐らく Neovim では動きません…。

以上、もし需要が合う方はお試しください。

Btrfs メモ

Btrfs についていろいろ調べたので使い方のメモ。

参考ページ:

このメモの内容はほとんど参考ページのままなので、そちらを見た方がよさそう。

Copy on Write

Btrfs では CoW (Copy on Write) が使える。ファイルを単にコピーしただけでは容量を消費せず、ファイルを書き換えた際に初めてディスク領域を消費する。

ただしこれはただ cp しただけではダメで、cp --reflink=always(常に CoW) や cp --reflink=auto(可能なら CoW) を使う必要がある。

そのため、私は以下のようなラッパコマンドを用意して PATH に置いた。

#!/bin/sh -
exec /bin/cp --reflink=auto "$@"

シェルの alias を使う手も考えられるが、コマンドごと置き換えることで例えば make install 時の cp の挙動も変えられるので、コマンド自体を上書きすることにした。

無効化

逆に CoW を無効にしたい場合もある。データベースが扱うファイルのように、1つのバイナリファイルを何度も上書きするような使い方をする場合、CoW と相性が悪いらしい。

ディレクトリやファイル単位で CoW を無効にする場合は以下のように chattr を使う。

$ chattr +C </dir/file>

また、マウント時に nodatacow オプションを指定することで対象を丸ごと CoW 無効にできる。後述する subvolume のマウントの際などに使える。

圧縮

透過的に圧縮ができる。zliblzozstd を選択できる。今だと lzozstd を選んでおくのが良さそう。ここベンチマークの結果がある。

マウントオプションで compress=lzocompress=zstd を指定する。compress=zstd:3 のようにすることで、圧縮レベルも指定できる。zstd の場合は 1 から 15 で、デフォルトは 3。

また、一緒に space_cache も指定するとよい。これは新規の割り当て領域を毎回探すのではなく、予めある程度キャッシュしてくれる。

圧縮は、圧縮を有効にした後に作成されたファイルにしか適用されない。既存のファイルにも適用し直すには以下のコマンドを使う。

$ btrfs filesystem defragment -r -v -czstd /

これは / から辿って再帰的(-r)に処理をするが、後述の subvolume や別の FS をマウントしている箇所等には適用されないので注意。

また、後述する snapshot を作っている場合も注意。全部のファイルを書き出し直しているのと同じになるので、snapshot とファイルを共有できずに容量を食ってしまう。

subvolume

ファイルツリーの単位として subvolume というものを作れる。見た目は普通のディレクトリのように見える。

$ btrfs subvolume create my-subvolume
Create subvolume './my-subvolume'

$ ls
my-subvolume

snapshot

subvolume 単位で snapshot を取れる。一瞬で作れる。

$ btrfs subvolume snapshot my-subvolume my-snapshot
Create a snapshot of 'my-subvolume' in './my-snapshot'

$ ls -1
my-snapshot
my-subvolume

CoW により、余計な容量を取らない。お手軽なバックアップに使える。

-r オプションを使うことで、read only な snapshot を作ることもできる。せっかく取ったバックアップを誤って書き換えてしまわないようにできるので便利だ。read only かどうかは後から変更もできる。

subvolume は入れ子にすることができるが、その場合、それぞれ独立した subvolume ということになる。snapshot を作った場合、入れ子になった subvolume までは snapshot の対象にはならない。

$ btrfs subvolume create my-subvolume/nested-subvolume
Create subvolume 'my-subvolume/nested-subvolume'

$ echo file1 > my-subvolume/file1.txt
$ echo file2 > my-subvolume/nested-subvolume/file2.txt
$ btrfs subvolume snapshot my-subvolume my-snapshot2
Create a snapshot of 'my-subvolume' in './my-snapshot2'

$ ls -1 my-snapshot2
file1.txt
nested-subvolume

$ ls -1 my-snapshot2/nested-subvolume
$

ネストした subvolume があった場所には空のディレクトリがあるが、中身はない。

subvolume のマウント

subvolume は、それ自体を通常のファイルシステムのようにマウントすることができる。マウントオプションに subvol={path}subvolid={id} を与える。subvolidbtrfs subvolume list で確認できる。

これにより、構成を工夫することで、本来のファイルシステムのルートとは別のところをルートディレクトリとして使い、

subvolume + snapshot を使って簡易タイムマシーン的な構成を作る

定期的に snapshot を取って、何かあったときに古いファイルを取り出せるようにしてみる。Mac のタイムマシーン、あるいは富豪的ゴミ箱みたいなイメージ。

構成

ArchWiki の Snapper のページにある推奨ファイルシステムレイアウトをほぼそのまんま採用した。

subvolid=5
   |
   |- @ (subvol)        ← / (root) をマウント
   |  |- /.snapshots    ← /@snapshots をマウント
   |  |- /usr
   |  |- /bin
   |  |
   |  |- ...
   |  |
   |  |- /home          ← 別パーティション
   |  |
   |  `- var/
   |     |- lib/
   |     |  |- docker/  ← /@var/lib/docker をマウント
   |     |  |- mysql/   ← /@var/lib/mysql をマウント
   |     |- log/        ← /@var/log をマウント
   |     |- tmp/        ← /@var/tmp をマウント
   |
   |- @snapshots/ (subvol)
   |  |- ... (snapshot)
   |
   `- @var/
      |  |- docker/ (subvol)
      |  |- mysql/  (subvol)
      |- log/ (subvol)
      `- tmp/ (subvol)

ファイルシステムの本当のルート(subvolid=5)を直接使わず、その直下の @ をシステムのルートディレクトリとしてマウントする。

log や tmp など、バックアップ対象にしたくない箇所はそれぞれ subvolume として外に出しておき、マウントする。

特定の snapshot に戻したい場合は、ArchWiki にまんま書いてあるが、USB で別システムでブートし、subvolid=5 をマウントしたあと、@ を削除 or 退避させて戻したい snapshot の snapshot を @ に作ればよい。

/home 以下も概ね同じだが、私はすでに /home/thinca を作ってしまっており、これをまるごと subvolume に変換する必要があったので以下の手順を行った。直接変換するようなコマンドはなく、cp するしかないらしい。

# cd /home
# btrfs subvolume create new
# cp -a -p -T --reflink=always thinca/ new/
# mv thinca old
# mv new thinca

snapshot の作成

snapshot を置いておく場所は用意できたので、ここに何かがあった時や定期的に snapshot を作っていきたい。

この手のことをするプログラムは、やはりみんな欲しいらしく結構な数が作られている模様。さきほどちらっと出た Snapper もその 1 つ。

試しに Snapper を使おうとしてみたが、動かし方がよくわからず…設定ファイルを書いてみても認識してくれない。

元々大したことをやろうというわけでもないので、やりたいことをやるスクリプトを書いてみた(たぶんこういうノリで謎スクリプトが量産されたのだろう…また1つ増えてしまった…)。

https://github.com/thinca/bsnap

使い方はざっくり README に書いたが、これで、//.snapshots に、/home/thinca/home/.snapshots/thinca に、それぞれ daily, weekly, monthly で snapshot を取る設定を書いた(weekly や monthly は記事執筆時点でまだ発動してないが…)。

例えば以下は / に関する daily の設定ファイル。形式は Bash script で、単にスクリプト変数を定義しているだけである。

# スナップショットのグループ
group=daily

# スナップショットを置くディレクトリ
snapshots_dir=/.snapshots

# スナップショットを取る対象のディレクトリ
target=/

# 最新のいくつを残すか
leave_count=30

# スナップショット作成時に cleanup を同時に行う
auto_cleanup=yes

これを /etc/bsnap/root-daily に置く。同じように home-daily やら root-weekly やらを置く。

で、これを定期的に実行するために systemd の service を作る。

# /etc/systemd/system/bsnap-create@.service

[Unit]
Description=Create a snapshot by bsnap

[Service]
Type=oneshot
ExecStart=/usr/local/bin/bsnap -c %i create

[Install]
WantedBy=default.target

この service を定期的に実行するために、timer を用意する。timer は daily、weekly、monthly で動かそうと思うので、それぞれのタイミングで発動するやつ、計3つ用意する。以下は daily のもの。

# /etc/systemd/system/bsnap-create-daily@.timer

[Unit]
Description=Create a snapshot by bsnap in daily

[Timer]
OnCalendar=04:00
Unit=bsnap-create@%i.service

[Install]
WantedBy=timers.target

あとは timer を有効にすれば、定期的に snapshot を取ってくれる。設定に従って古いものは自動的に削除してくれる、はず。

# 以下を、root/home それぞれと daily/weekly/monthly それぞれで計 6 個有効にする
$ sudo systemctl start bsnap-create-daily@root-daily.timer
$ sudo systemctl enable bsnap-create-daily@root-daily.timer

rm 時に snapshot を作成

また、rm を関数で wrap して、削除前に snapshot を取るようにした。

  • 一般ユーザー向けなので $HOME 以下のファイルを消そうとした場合のみ
    • 専用の home-rm という設定を作っておく
    • その場合は -fr がなくてもディレクトリを消せるようにする
      • こうしておくと、常に -fr なしでもファイルもディレクトリも消せる。そういう癖を付けておけば、うっかりゴミ箱がない環境で手軽にディレクトリを消そうとしたときに一旦エラーで止まって一呼吸置けるようになる
  • $HOME の中と外のファイルを同時に消そうとするとまずいが、そうそうしないと思うので目を瞑る
  • それ以外にも完璧に rm を置き換えるのは関数では難しく、妥協している
if [[ -e /etc/bsnap/home-rm ]]; then
  rm() {
    local arg snap=no will_err=no paths=()
    for arg in "$@"; do
      if [[ "${arg[1]}" != "-" ]]; then
        paths+=("${arg}")
        if [[ ! -e "${arg}" ]]; then
          will_err=yes
        elif [[ "$(realpath ${arg})" =~ ^"${HOME}" ]]; then
          snap=yes
        fi
      fi
    done
    if [[ "${snap}" == "yes" && "${will_err}" == "no" ]]; then
      bsnap -c home-rm create "$(basename "${paths[1]}")"
      command rm -fr "${paths[@]}"
    else
      command rm "$@"
    fi
  }
fi

雑感

Btrfs は調べれば調べるほど色んな機能が出てきて驚かされる。

まだ試せてない機能が他にもあるので、機会をみつけて触っていきたい。

nftables の設定を書いた時のメモ

前回に引き続き、新しいマシンの環境構築をしている。

以前のマシンではファイアウォールとして、以前は iptables を使用していたが、今回 nftables に移行してみる。元々 iptables 自体、かなり昔に書いた設定を使い回していて、設定方法はすっかり忘れてしまっていた。

あらかた設定できたので、その際のメモ。

ネットワーク環境

マシンが置かれているネットワークの環境は以下のような感じ。

  • ルータの内側にいるサーバマシン
    • なのでほとんどの通信はルータの時点でシャットアウトしてもらえる
    • とは言え、可能な限りインターネットに晒されている前提の設定を目指す
  • しかし IPv6 は今回は設定しない
    • 将来的には挑戦したい…
    • プロバイダから IPv6 アドレスを当てられていないので、IPv6 の通信は実際には来ないはず
  • 将来的には Wi-Fi の AP にもしたい
    • が、それはまたの機会に
    • こちらを開くとルータ内部の前提が崩れるので IPv6 も含めてしっかりやらないといけなさそう

設定方針

  • 公開するポートは http(80), https(443), ssh(22 は使わず、別のポートを使う) のみ
  • ssh ポートは、DOS アタックに対して一定時間のブロックを行う

nftables についての資料

あちこちのブログ記事等も参照したが、最終的には公式 Wiki が一番だった。

https://wiki.nftables.org/wiki-nftables/index.php/Main_Page

説明足らずな部分もあったけど、見付けられた資料の中では一番詳しかった。

コマンドライン

nft コマンドを使う。

このコマンドで 1 つずつルールを足していくこともできるが、今回はルールファイルを用意してそのファイルを読み込ませる方法のみを使った。

設定中に使っていたのは以下のコマンドのみ。

コマンド 説明
nft -f {ファイル名} 設定ファイルを読み込んで適用する。
nft list ruleset 現在適用されている設定を表示する。

作った設定ファイルは最終的には /etc/nftables.conf に配置し、systemctl start nftables.service でロードできる。systemctl enable nftables.service で起動時に設定を適用できる。

設定の書き方

特にここからは、個人的に理解したことをざっくりベースで書いていく。理解が間違っている可能性も大いにある。

nftables の ruleset は複数の table を持っており、table は複数の chain を持っており、chain は複数の rule を持っている。table と chain は名前を持っている。

設定の初期化

設定は nft -f で読み込まれる度に追記される。それは困るので、ファイルの先頭に全ての設定を削除するコマンドを入れておく。

flush ruleset

設定ファイルを読み直す度に最初にルールが全て削除されるので、常にファイルに書かれた状態にできる。

なお、nftables はルールをアトミックに変更してくれるので、「flush した一瞬だけルールがない状態でパケットが処理される」ということはない。この辺りも iptables に対する強みの1つ。

table

table はプロトコルファミリーごとに作成する。

# IPv4 用
table ip filter {
}

filter という名前の IPv4 プロトコル向けの table を作った。ファミリーは他に、ip6(IPv6 向け) inet(IPv4/IPv6 両方向け) 等々。詳細はココ

この table のブロック { ... } の中に chain を書いていく。

chain

chain は大きく分けて base chain と non-base chain がある。

base chain

base chain は処理の入口になる chain で、先頭に type <type> hook <hook> priority <value>; のようなものがある。これはこの chain が処理するパケットの条件で、これにマッチするパケットがその chain で処理される。

table ip filter {
  chain input {
    type filter hook input priority filter; policy drop;

    # ... rules ...
  }
}

input と言う名前の chain を作った。なお table もそうだが、名前はあくまで識別のための名前であり、名前自体に意味はない。好きな名前で大丈夫。

type に指定できるのは filter route nat、filter に指定できるのは prerouting input forward output postrouting ingress みたいな感じ。詳細は公式 Wiki の該当ページを参照。 入ってきたパケットは条件に合う chain を priority の小さい順に処理されていく。priority は数字で、一部の数字には別名がある。priority filterpriority 0 と同じ。負数もある。同値の場合は処理順は不定

non-base chain

non-base chain は base chain 以外のもの。先頭に条件がなければ non-base chain になる。jump や goto などのルールで他の chain から飛んでくる。飛んでこない限り chain は参照されない。

rule

chain の中には rule を順番に書いていく。上から順に処理される。

rule は matches による条件の絞り込みと statements によるパケットに対するアクションを好きな順序で並べていく。

# NEWだがSYNが立っていないパケットを破棄
tcp flags & (fin | syn | rst | ack) != syn ct state new counter log prefix "NEW not SYN:" drop
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ ~~~~~~~~~~~~ ======= ========================= ====
  + matches (TCP のフラグをチェック)        |            |       |                         |
                      matches (conntrack の状態が new)   |       |                         |
                      statements (通過したパケット数をカウント)  |                         |
                                                               statements (log に残す)     |
                                                                      statements (パケットを drop する)

個々の matches と statements の境界が文法上は特になく、わかってないとどこで切れているのかがわからないのでわかりづらい。

matches と statements はいくつでも書けるし、順番が混ざってもよい。左から順に処理される。例えば、

# 仮の文法
<stmt1> <match1> <stmt2> <match2> <stmt3>

みたいに書けば、まず stmt1 は常に実行され、次に match1 にマッチしたパケットには stmt2 が実行され、さらに match2 がマッチしたパケット(つまり match1match2 両方にマッチしたパケット)は stmt3 が実行される。

dropaccept などの一部の statements は Verdict statements と呼ばれていて、パケットの扱いが決まる。 この場合はものによって、chain での処理を中断して chain を抜けたりする。別の chain に飛ぶ、jump (終わったら戻ってくる)や goto (終わっても戻ってこない) も Verdict statements の 1 つ。

chain の最後まで行った場合、chain のポリシーによって扱いが決まる。base chain の先頭に書いてた policy drop; みたいなやつ。これは acceptdrop のどちらかで、書いてない場合はデフォルトで accept になる。

個人的な設定

最後に現時点での個人的な設定を貼っておく。コメントが英語だったり日本語だったり、割と雑。どなたか、もしなにかまずそうな部分を見付けたら、こっそり教えてください…。

#!/usr/bin/nft -f

flush ruleset

define localnet = 192.168.1.0/24

table ip filter {
  set dos_counter {
    type ipv4_addr . inet_service
    flags dynamic
  }
  set block_targets {
    type ipv4_addr . inet_service
    flags timeout
  }

  chain input {
    type filter hook input priority filter; policy drop;

    # セッション確立後のパケットは許可
    ct state established,related counter accept

    # early drop of invalid connections
    ct state invalid counter drop

    # allow from loopback
    iifname "lo" counter accept

    # SYN/ACKでNEWなパケットに対してRSTを送る
    tcp flags & (syn | ack) == syn | ack ct state new counter log prefix "SYN/ACK and NEW:" reject with tcp reset

    # NEWだがSYNが立っていないパケットを破棄
    tcp flags & (fin | syn | rst | ack) != syn ct state new counter log prefix "NEW not SYN:" drop

    # allow from local network
    ip saddr $localnet ct state new tcp dport . ip protocol {
      53 . tcp, 53 . udp,  # DNS
      123 . udp, # NTP
      137 . udp, 138 . udp, 139 . tcp,  # Windows
      445 . tcp,  # Active Dierctory
      22 . tcp, # ssh
      17500 . tcp, 17500 . udp, # Dropbox (LAN sync)
    } counter accept

    tcp dport 22 tcp flags & (fin | syn | rst | ack) == syn ct state new goto dos_detection

    ct state new tcp dport . ip protocol {
      80 . tcp, 443 . tcp,  # HTTP/HTTPS
    } counter accept

    # Ident(113)を拒否(DROPするとレスポンスが遅くなるのでReject)
    tcp dport 113 counter reject with tcp reset

    # ping
    icmp type { echo-reply, echo-request } counter accept
  }

  chain output {
    type filter hook output priority filter; policy accept;
    oifname "lo" counter accept
  }

  chain dos_detection {
    counter
    add @dos_counter { ip saddr . tcp dport limit rate over 10/minute burst 5 packets } counter jump add_block
    ip saddr . tcp dport @block_targets counter drop
    counter accept
  }

  chain add_block {
    # Logging only first time
    ip saddr . tcp dport != @block_targets log prefix "DOS Attacked:"
    update @block_targets { ip saddr . tcp dport timeout 30m }
  }
}

ssh はここでは 22 と書いているが、冒頭でも書いた通り実際には別のポートに変えている。

同じく冒頭で書いた DOS アタックのブロックは dos_detection chain と add_block 辺り。ただの rate limit ではなく、検出したら block_targets に放り込んで 30 分ずっと拒否する。

この検出の部分に limit rate を使ってみたのだけど、なんとなくそれっぽく動いているのだけど、実際にどういう処理になっているのかがいまいちよくわかっていない…。

余談: バグ

ところで、ポート番号とかを { ... } で列挙している部分は Set というもので、これを使うことで1つずつルールを書くよりもテーブルジャンプができることで処理が効率化できるみたい。 が、この文法を複数行で書く場合は注意が必要で、文法上のバグがある。

tcp dport {
  80,  # ここにはコメントが書ける
  # ここにはコメントが書けない!
  443,
} counter accept

コメントと書いたが、実際に書けないのは空行である(コメントだけの行も空行扱い)。 すでにバグトラッカーには登録されているみたいだけど、地味に不便なので直ってほしい…。 (opt_newline が任意の個数の改行を処理してくれればよさそうではあるけど、あまり深追いしてない)

Arch Linux インストールメモ

新しく PC を購入し、Arch Linux をインストールしたので、その作業の個人用メモ。 あとから思い出しながら書いているので、実際に実行したコマンド等は省略気味。実際には紆余曲折があったが、その辺りも省略して、最終的にやったことの記録(それも思い出せる範囲で)。

用途

ssh でログインして使う開発マシン兼おもちゃ用途の汎用自宅サーバ

すでに 1 台同じ用途のがあり、特に問題なく使えているが、5 年前に買ったものなのでスペックアップも兼ねて買い替えることにした。

以前は Gentoo を使っていたが、別のものも使ってみたいと思っていたので、今回は Arch Linux にしてみる。インストールは初めて。

マシン

パーツ 商品 スペック 価格 コメント
ベアボーンPC Intel BXNUC10I7FNH Core i7-10710U (CPU) ドスパラWEB 67,660 円 通称 NUC。あまり深く考えずに選んだ。前から気になっていたシリーズではある。
メモリ G.SKILL F4-2666C18S-32GRS x2 SO-DIMM DDR4-2666MHz 32GB ドスパラWEB 12,693 円 x2 気付いたら 2 枚買っていた。絶対使いきれないと思うが後悔はしていない。
ストレージ CT1000P1SSD8JP M.2 SSD 1TB アマゾン ジャパン 12,800 円 NUC には 2.5 インチ SSD も載るが、これで足りるのでそちらは空けてある。
電源コード KB-DM3S-1 3P ストレートプラグ 1m ヨドバシ.com 624 円 上記が一通り届いた後でこれも必要なのに気付いて買い足した…圧倒的確認不足。

計 106,470 円。メモリもりもりにした割には安い。

インストール方針

ざっくり方針。

なお、ドライブ暗号化や btrfs の導入は個人的には今回が初めて。

インストールメディア作成

Windows マシンで作業。

Arch Linux のダウンロードページからインストールイメージ(archlinux-yyyy.mm.dd-x86_64.iso のようなファイル)をダウンロード。

自宅にあった 4GB の USB メモリに Rufus を使ってイメージを書き込み。特筆すべき点はなし。

インストール

基本は ArchWiki のインストールガイドに従って進める。

パーティション作成

parted を使ってパーティションを切る。

最終的な構成。

モデル: CT1000P1SSD8 (nvme)
ディスク /dev/nvme0n1: 1000GB
セクタサイズ (論理/物理): 512B/512B
パーティションテーブル: gpt
ディスクフラグ:

番号  開始    終了    サイズ  ファイルシステム  名前      フラグ
 1    1049kB  1074MB  1073MB  fat32             efi-boot  boot, esp
 2    1074MB  550GB   549GB                     root
 3    550GB   1000GB  450GB                     home

なんとなく root と home は分けた。ざっくり 2 分割。

実は最初は /boot 用と /boot/efi 用でパーティションを分けてそれぞれ 512MB ずつ振っていたのだが、作業を進めるなかでブートローダーに systemd-boot を使おうとしたところ、そのような構成には対応していないとのことだったので、仕方なく 2 つのパーティションをがっちゃんこしたのだった。 root 用のパーティションはすでに暗号化 + 各種ファイルの配置が終わっていて、開始位置をずらすのは面倒だったし、512 MB くらいケチってもしゃーないので割とリッチなサイズの /boot になった。

ディスク暗号化

パーティションのうち、root と home は暗号化する。暗号化には dm-crypt + LUKS を使う。

何もわからないのでいつも通り ArchWiki の dm-crypt の項目を読んだり人に聞いたりしながら進めた。

鍵は外付けのストレージに置き、刺していないと起動できない構成にする。 外付けストレージは、自宅に転がっていた 2GB の SD カードを使う。もう 10 年近く前の代物…。余裕で壊れる可能性があるので当然鍵はバックアップする(暗号化されているデバイス上に保存されていなければよい)。

まずは鍵ファイルを作成する。これは適当なサイズのランダムなバイナリデータでよい。適当なサイズがどれくらいなのかが全然わからなかったので、適当に 8192 バイトの鍵ファイルを作った。

# mkdir /mnt/sd_card
# mount /dev/mmcblk0p1 /mnt/sd_card
# dd bs=512 count=16 if=/dev/urandom of=/mnt/sd_card/root.key
# dd bs=512 count=16 if=/dev/urandom of=/mnt/sd_card/home.key

この鍵を使ってパーティションを暗号化する。

# cryptsetup -v luksFormat /dev/nvme0n1p2 /mnt/sd_card/root.key
# cryptsetup -v luksFormat /dev/nvme0n1p3 /mnt/sd_card/home.key

作った暗号化デバイスをオープンする。

# cryptsetup open --type luks /dev/nvme0n1p2 crypt-root --key-file=/mnt/sd_card/root.key
# cryptsetup open --type luks /dev/nvme0n1p3 crypt-home --key-file=/mnt/sd_card/home.key

それぞれ名前を指定していて、 /dev/mapper/crypt-root のような名前でアクセスできる。このデバイスを操作すれば、暗号化されたデバイスを通常のデバイスのように操作できる。

ディスクマウントまで

まずはフォーマット。 /bootEFI System Partition なので FAT32 でフォーマットする。

# mkfs.fat -F 32 /dev/nvme0n1p1
# mkfs.btrfs /dev/mapper/crypt-root
# mkfs.btrfs /dev/mapper/crypt-home

そしてマウント。

# mkdir /mnt/arch
# mount /dev/mapper/crypt-root /mnt/arch
# mkdir /mnt/arch/home
# mount /dev/mapper/crypt-home /mnt/arch/home
# mkdir /mnt/arch/boot
# mount /dev/nvme0n1p1 /mnt/arch/boot

マウントをしたので fstab を生成しておく。

genfstab -U /mnt/arch >> /mnt/arch/etc/fstab

インストール

マウントしたデバイスに対して Arch Linux の必要なファイルをインストールする。

# pacstrap /mnt base btrfs-progs linux linux-firmware

今回は btrfs を採用したので、 btrfs-progs パッケージを指定する必要があった。

chroot

Arch Linux には arch-chroot というのがあるのでカンタン。Gentoo の時は結構面倒だった(今は変わってたりするんだろうか)。

# arch-chroot /mnt/arch

細かな設定

おおむねインストールガイド通り。

タイムゾーンの設定

# ln -sf /usr/share/zoneinfo/Asia/Tokyo /etc/localtime
# hwclock --systohc

言語設定

/etc/locale.gen に以下を追記。

ja_JP.UTF-8 UTF-8
en_US.UTF-8 UTF-8

以下のコマンドでロケールを生成。

# locale-gen

/etc/locale.conf に以下を書き込んで起動時のデフォルトにしておく。

LANG=ja_JP.UTF-8

コンソールの設定

普段は ssh で接続する前提なのでコンソールはインストール作業や緊急時くらいにしか使わないんですが。

コンソールではCaps Lock を Ctrl に置き換えておく。Arch Linux では keymap ファイルは /usr/share/kbd/keymaps/i386/qwerty/ 以下にあった。

以下のコマンドでキーボードのキー配列を変更する。jp106-caps-ctrl は時前で用意した Caps Lock を Ctrl に置き換えた配列。

# loadkeys jp106-caps-ctrl

/etc/vconsole.conf に以下を書き込んで起動時のデフォルトにしておく。

KEYMAP=jp106-caps-ctrl

ネットワーク設定

/etc/hostname/etc/hosts をサクッと設定する。

dhcpcd を入れておく。実はインストール中に入れ忘れて起動後にネットに繋がらなくて後から入れ直した。

# pacman -S dhcpcd

root のパスワード

忘れずに設定しておく。

# passwd

ブート設定

先に少し触れた通り、今回はブートローダーに systemd-boot を使う。個人的には今までは GRUB を使っていた。特に GRUB には不満はなかったが、最近は猫も杓子も systemd という風潮を感じるので、せっかくだから systemd に寄せてみるか、程度の動機。

ArchWiki の systemd-boot の項目に沿って進める。

まずは EFI システムパーティション(/boot)に systemd-boot をインストールする。

# bootctl --path=/boot install

Arch Linux を起動するためのローダーを追加する。GRUB では自動でやってくれた部分が手で書くしかないと知った時は絶望した。将来に期待したい…。

/boot/loader/entries/arch.conf に以下の内容のファイルを作成する。

title Arch Linux
linux /vmlinuz-linux
initrd /intel-ucode.img
initrd /initramfs-linux.img
options luks.uuid=84b7c414-4d5a-4958-ae55-c5beb137acd8
options luks.name=84b7c414-4d5a-4958-ae55-c5beb137acd8=encrypt-root
options luks.key=84b7c414-4d5a-4958-ae55-c5beb137acd8=/root.key:UUID=4081-FA31
options luks.uuid=fb18406d-4cee-404a-b33a-bd3163cef23d
options luks.name=fb18406d-4cee-404a-b33a-bd3163cef23d=encrypt-home
options luks.key=fb18406d-4cee-404a-b33a-bd3163cef23d=/home.key:UUID=4081-FA31
options root=UUID=09afad0f-0fd9-4464-8980-2fddd6f5e15c rw

ここが一番ハマった。

luks.uuid には、暗号化されたパーティションの UUID を指定する。 luks.name には、暗号化されたパーティションをオープンする際の名前(/dev/mapper/{name} で参照できる名前)を {UUID}={name} の形式で指定する。 luks.key には、暗号化に使った鍵ファイルの場所を {UUID}={path}:{partition} の形式で指定する。{partition} 内の {path} にある鍵ファイルが使われる。 root は通常通り。当然複合化されたパーティションを指定する必要がある。

参考までに私の環境での各パーティションの UUID なんかの情報を載せておく。

# blkid
/dev/nvme0n1p1: UUID="8FDB-7D9C" BLOCK_SIZE="512" TYPE="vfat" PARTLABEL="efi-boot" PARTUUID="56d44349-149f-4f0c-83b8-d81f15ce7afd"
/dev/nvme0n1p2: UUID="84b7c414-4d5a-4958-ae55-c5beb137acd8" TYPE="crypto_LUKS" PARTLABEL="root" PARTUUID="52179ba4-a678-48bc-b2f0-89609ddc7c25"
/dev/nvme0n1p3: UUID="fb18406d-4cee-404a-b33a-bd3163cef23d" TYPE="crypto_LUKS" PARTLABEL="home" PARTUUID="a0806d30-22bd-49a1-9de1-acffbad6321f"
/dev/mmcblk0p1: SEC_TYPE="msdos" UUID="4081-FA31" BLOCK_SIZE="512" TYPE="vfat"
/dev/mapper/encrypt-root: UUID="09afad0f-0fd9-4464-8980-2fddd6f5e15c" UUID_SUB="77041826-2e8c-49b9-b3d0-f7267ac8c231" BLOCK_SIZE="4096" TYPE="btrfs"
/dev/mapper/encrypt-home: UUID="6e980051-5837-4be6-aff2-00551a20339a" UUID_SUB="3a3e48ee-2c56-4a48-97b4-55f0d2779723" BLOCK_SIZE="4096" TYPE="btrfs"

最初は /etc/crypttab ファイルや /etc/crypttab.initramfs ファイルに記載してブートを試みたが、うまくいかず。恐らく書式が間違っていたのだと思う(調べるのをやめてしまったので真相は不明…)。起動時のオプションから渡すようにしたらうまくいった。

マイクロコード

前述の arch.conf 内で参照している intel-ucode.img を用意するために intel-ucode をインストールする。

# pacman -S intel-ucode

再起動

うまく起動するように祈る。

# systemctl reboot

実際には1度でうまく行く訳がなく、何度も再起動した(主にブートローダーの設定辺り)。

無事起動できたら、一般ユーザーの作成とかをやっていく。

謝辞

インストール作業にあたって、vim-jp Slack の #os-linux チャンネルの皆さんに大変御助力頂きました。本当にありがとうございます。ログはこの辺り

dein.vim でのプラグインの更新チェックを爆速にする

先日、Vimプラグインマネージャである dein.vim に、プラグインの更新チェックを劇的に高速にする変更が取り込まれた。使うには設定が必要なので、その方法を紹介する。

dein.vim を最新版にする

言うまでもないが、まずは dein.vim を最新版にする。

:call dein#update('dein.vim')

GitHub の Personal Access Token を生成する

この機能を使うには、GitHub の Personal Access Token を用意する必要がある。

https://github.com/settings/tokens

GitHub にログインして上記のページにアクセスし、Generate new token を押す。token の用途がわかりやすいように名前を付け、Generate token を押す。追加の権限は必要ない。生成された token をなくさないようにメモし(ページ遷移すると二度と見れないので注意。メモし忘れた場合は再生成する)、vimrc に以下のように設定する。

let g:dein#install_github_api_token = 'your token'

your token のところに生成した token を書く。言うまでもないが、この token は人に知られてはまずいものなので、vimrc を公開しているのであれば別のファイルに分離するなどの工夫が必要だ。

更新方法を変更する

通常、プラグインの更新には dein#update() を使っていたと思うが、新しい方法では dein#check_update() の第1引数に TRUE な値を渡すことで行う。

call dein#check_update(v:true)

特定のプラグインだけ更新したい場合はプラグインの一覧を第2引数に渡す。

call dein#check_update(v:true, ['dein.vim'])

プラグインを100個単位で入れていたとしても、あっという間に更新のチェックが終わる。

仕組み

ここからはオマケで、高速化の仕組みについて。

Vimプラグインはそのほとんどが GitHub 上で公開されている。今までは、プラグインの新しいバージョンが公開されているかどうかを、リポジトリ 1 つずつチェックしていた。当然 1 件ずつ通信を行うため、プラグインを100個も200個も入れていると、とても時間がかかる。

新しい方法では、GitHub の GraphQL API を使い、リポジトリを 100 件単位で更新があるかどうかをチェックする。このために Personal Access Token が必要になる。

GitHubREST API では 1 度のリクエストでアクセスできるリポジトリは 1 つだけになってしまうが、GraphQL API であれば 1 度の API リクエストで 100 件のプラグインのチェックができるため、更新されたプラグインがなければ非常に高速にチェックが終了する、というわけだ。

この手法については私が雑談レベルで提案していたものだが、今回 Shougo さんがものすごい速さで実装してくれた。ありがとうございます!

更にオマケ: 別の高速化の話

プラグインマネージャは更新処理の際、「今ローカルにインストールされているプラグインのバージョン(Git のコミット)はいくつか」を知る必要があるが、Windows では Linux などに比べると外部プロセスの起動が非常に遅く、リポジトリ毎に git コマンドを実行するだけでも数が多いとかなり時間がかかっていた。

別のプラグインマネージャである minpac ではこの問題を改善するために、「Git のコミットを得るために git コマンドを実行せずに直接 .git/ ディレクトリ内のファイルを読む」という手法を採用していたが、このたびこの方法が dein.vim にも取り込まれた

よって特に Windows ユーザーは以前に比べて更に高速になっている。

なお、こちらの変更は特に設定をしなくても恩恵を受けられる。

Vim で q を prefix キーにする

前置き

Vimプラグインをたくさんいれていると、それらを呼び出すためにキーマッピングを用意したくなることはよくある。

Vim はキーシーケンスに対してマッピングを割り当てられるので、何かのキーを prefix キーとして、そのキーに続けて何かしら機能を割り当てると言うテクニックがよく用いられる。

" スペースキーを prefix にする例

" スペースキー単体では何も起きないようにする
" これをしておかないと、うっかり <Space> + 割り当ててないキーを
" 押すと <Space> の元の機能が発動する
nnoremap <Space> <Nop>

" <Space>q でウィンドウを閉じる
nnoremap <silent> <Space>q :<C-u>quit<CR>

" ... 以下、<Space> + 何かにキーを割り当てられる

しかしこの方法をもってしても、割り当てたい機能はどんどん増えていくものである。prefix キーとして利用しやすいキーはいくつかあるが、それらが枯渇するほど機能を割り当てている人も世の中にはいるようだ。

q を prefix にすることで起きる問題

q は、マクロの記録を開始するコマンドである。q + レジスタのアルファベット1文字で、そのレジスタに対してマクロの記録を開始する。

しかし、26個もあるマクロを全て器用に使いこなしている人はおそらくまずいないと思う。私なんてだいたい1つしか使わない。どこに何が記録されているか、覚えられないからだ。

となると、残りの q + アルファベットは空いていることになる。q を prefix にすればこれらが使える。何ならアルファベット以外の文字も使えることになる。

nnoremap q <Nop>
nnoremap qa :<C-u>echo 'Awesome feature!'<CR>

こうすることで、qa は無事便利機能に割り当てることができる…が、こうしてしまうと、q<Nop> にすることで、マクロの開始が一切できなくなってしまう。

これはさすがにまずい。しかしこれは単に q<Nop> を割り当てなければ解決する。

nnoremap qa :<C-u>echo 'Awesome feature!'<CR>

マクロの記録も開始できる。これで何も問題はない…?

更に潜む問題

Vim のマクロの記録は q 単体で終了する。ところが、q で始まるキーマッピングがあると、押された q がマクロの終了であるのか、はたまたキーマッピングの呼び出しなのかを判断するため、Vim はしばらくの間キーマッピングの待受をする。

待受時間を待ってやり過ごすか、q で始まるキーマッピングではない何か別のキーのコマンドを実行すれば済むのだが、いかんせんやきもきしてしまう。やはり prefix 以外のキーマッピング待ちは発生させたくない。

解決策

今回、この問題を解決するために以下のように設定してみた。

nnoremap <script> <expr> q reg_recording() is# '' ? '<SID>(q)' : 'q'

nnoremap <silent> <SID>(q) q
nnoremap <silent> <SID>(q)a :<C-u>echo 'Awesome feature!'<CR>
nnoremap <silent> <SID>(q)<Space> :<C-u>quit<CR>

q を押した際に、マクロの記録中であれば即座に q を起動してマクロの記録を終了させ、そうでなければ prefix としての q を呼び出す。prefix の設定がない場合はそのまま q として機能させることで、マクロの記録開始も行えるようにする。

こうすることで、q を prefix として機能させつつマクロの記録開始/終了に影響を与えないようにすることができる。私は <Space>q をよく q<Space>誤爆することがあったので、同じ機能を割り当ててみた。

今回はなんとなく <SID> を使ってみたが、q prefix のキーマッピングを複数のスクリプトで定義したい場合は <Plug> を使っても問題ないだろう。

nmap <expr> q reg_recording() is# '' ? '<Plug>(q)' : 'q'

nnoremap <silent> <Plug>(q) q
nnoremap <silent> <Plug>(q)a :<C-u>echo 'Awesome feature!'<CR>
nnoremap <silent> <Plug>(q)<Space> :<C-u>quit<CR>

この場合は q のキーマッピングnnoremap ではなく nmap を使うことに注意する必要がある。

残る問題

ここまでやっても一応まだ問題は残ってる。ここで定義した q で始まるキーマッピングは、マクロの記録中に呼び出すことはできない。 しかしこれはある意味当然であり、マクロ記録中に呼び出したい機能を q に割り当てないようにする他はない。