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 に割り当てないようにする他はない。

Vim Short Tips Advent Calendar を開催しました

全然ブログを更新しない thinca です。

さて、今年も Advent Calendar の季節がやってきました。あちこちで大量の知見が出回っていますね。

記事が増えるのはいいことなんですが、一方で私はこの流れにあるつらみも感じていました。

書くのがつらい

長編記事は書くのが大変です。いや、別に誰も長編記事を書いてくれ、とは言っていません。

しかし上がってくる記事はどれも力作。こんな流れの中、しょぼい記事は上げづらい、なんて感じたことはないでしょうか。

特に日本人は空気を読むように訓練されている節があるので、ハードルが高いと感じてしまう人もいそうな気がします。というか私は大作記事は大変なのでできれば書きたくないです…。

読むのがつらい

特にこの時期に大量の記事が出回ることになるので、楽しみな反面、読むのが大変です。

途中で挫折して読むのをやめてしまったカレンダー、ありませんか。

試験的な試み

そこで今回試しにやってみたのが、Vim Short Tips Advent Calendar です。

内容を、Twitter の 1 ツイートに収まるものに限定し、書く手間と読む手間を下げよう、という試みです。テーマが Vim なのは私が Vim 界隈で活動しているからですね。

内容も、高度なものである必要はありません。というか短いので高度なことは書けません。知ってるとちょっと便利な Tips がメインです。

実はこれ、11 月中にカレンダーがあちこちで立てられるなかぼんやり考えていたものなんですが、vim-jp コミュニティ内でコンセプトを説明して参加者を募ったのが 11/30 の 20 時頃。実際にカレンダーを立てたのはその1時間後の 21 時頃というギリギリでの開始でした。

にも関わらず、参加者はあっという間に集まり、最初数日分は即座に埋まり、その辺りを走っているうちに最後まで全てメンバーが埋まりました。コンセプトの狙い通り、参加のハードルを下げたのが功を奏したのだと思っています。

参加者や各 Tips を見てくれた人からも概ね好評だったように思います*1。こういうやり方もありだな、と感じました。

Tips 一覧

せっかくなので Tips 一覧を引用してみたいと思います。短いので全部並べてもサクッと読めますね。お気に入りの Tips を見付けたら、ぜひいいねや RT をしてあげてみてください。

1日目

2日目 3日目 4日目 5日目 6日目 7日目 8日目 9日目 10日目 11日目 12日目 13日目 14日目 15日目 16日目 17日目 18日目 19日目 20日 21日目 22日目 23日目 24日目 25日目

おまけ

*1:アンケートを取ったわけではないので私が感じた限りでは

VimConf 2019 が開催されて1週間が過ぎました

今年も無事 VimConf を終えることができました。私は今年もスタッフの一人として参加しました。参加してくれた皆さん、発表してくれた皆さん、スタッフ、そのほか関係者の皆さん、ありがとうございました。おつかれさまでした。

1週間経ったこともあり、感想記事が続々上がってきています。また、(遅くなりましたが)参加した皆さんにアンケートのお願いも送信し、続々と回答が集まっています。

VimConf の運営スタッフは全てボランティアです。スタッフには金銭的な報酬は一切ありません。まあこれは別に珍しいことではなく、大抵のコミュニティドリブンのカンファレンスはどこもそうだと思います。

それでいてやることは山のようになります。みんなプライベートの時間を切り崩してなんとか対応しています。みんなできる範囲でやっているので、負担が偏ったりもします。たぶん私は他のメンバーに比べると負担は軽かった方。みんなありがとう。

話が逸れた。こういった状況の中で、多くの感想記事やアンケートでの意見を拝見していると、大変だったけどやってよかった、と思えます。今のところ発見できた感想記事は全て目を通しています。ありがとうございます。

イベントの盛り上がりと継続と言う意味で、フィードバックは非常に重要です。「もうみんな書いてるし…」「イベントから時間が経ってしまったから…」そんなことはありません。今からでも遅くないです。あなたの記事を見た誰かが、次回は参加したいと思うかもしれません。

…感想を書こうと思ったら感想にすらなっていないポエムになってしまった。 ともあれ、カンファレンスは、参加してくれる皆さんがいてくれてこそ成立しています。そして参加してくれた皆さんがまた参加したいと思ってくれたのであれば、運営スタッフの一人としてこれほど嬉しいことはありません。

ライブコーディングで作ったプラグインを整理して公開した

先日、ゴリラ.vim #9 に参加してライブコーディングをしてきた。

gorillavim.connpass.com

その際に作ったプラグインを整理して最低限の形した。整理する過程で色々変わったのでその話など。

公開したプラグインは以下。

github.com

どんなプラグインなのか

これは日本語における、ひらがな⇔カタカナの変換や全角⇔半角の変換をするプラグイン

極稀に欲しくなる操作で、サクラエディタなどには標準で付いている。Vim でもやりたかったので作ってみることにした。ネタとしても小さめでライブコーディングでもなんとかなるかなという目論見。

試験的なプラグインということもあって、無駄に最新バージョンの Vim を要求する。scriptversion 4 が通らない Vim では動かない。

プラグイン

変換するという想定でライブコーディング時には convja.vim という名前で作り始めた。

しかしあとであれこれ考えて、文字種の判定や抽出もできると便利かも(やるとは言っていない)と思い、汎用的な処理ができるように jautil.vim という名前にしてみた。無駄に汎用化してしまうのは私の悪い癖である。わかってはいる…。

インターフェース

ライブコーディング時では、以下のようなインターフェースを想定していた。

echo convja#convert('123アイウ', {'target': 'hankaku', 'type': 'kana'})
# => '123アイウ'

しかし作っているうちに紆余曲折があり、もっとシンプルに指定できるようにしたいなぁという気持ちになったので以下のような形式に落ち着いた。

echo jautil#convert('123アイウ', 'hankaku:kana')
# => '123アイウ'

簡易とはいえ特殊な文法を知る必要があるが、まあ辞書の形式を知るのと大差ないだろう。

拡張

第2引数の target に配列を渡せるようにした。これにより、まず半角カナを全角カナにし、その後全角カナをひらがなにすることで全角/半角両方のカナをひらがなに直す、ということが1度の呼び出しでできる。

echo jautil#convert('アイウエオカきくけこ', ['hankaku:kana', 'katakana'])
# => 'あいうえおかきくけこ'

また、第1引数に配列を渡すことで第1引数と第2引数を入れ替えられるようにした。これにより、partial を使って特定の変換をする関数を用意することができるようになる。

let H2Z = function('jautil#convert', [['hankaku']])
echo H2Z('abc')
" => 'abc'

今後の展望

あれこれこうできたらいいなと思っていることはあるが、優先度は激低なのでやるかは不明。ライブラリだし vital module 化したいよなー。

もっとちゃんと使いたいぜって人がいたら Issue を立てたりしてください。やるかどうかはわからないですが…。