🎉

zellij floating windowをpinentry-cursesのフロントエンドにする

に公開
2

zellij floating windowをpinentry-cursesのフロントエンドにする

下記記事のzellij版です

https://zenn.dev/ngicks/articles/pinentry-in-tmux-popup

下の画像のようになります。

But Why?

https://zenn.dev/ngicks/articles/pinentry-in-tmux-popup

で説明したとおりですが、要するに

  • いろんな環境から開発環境に入れるようにするためにzellijを使う。
    • tailscaleなどのVPNソリューションを使って仮想的LAN内にお互いがある状態にし、端末から開発環境にsshします。
  • zellijはterminal multiplexer/session managerであり、作成したterminal sessionに複数のクライアントからログインできるようにします。
  • 開発すればgit commitもします。commitには基本的に鍵を使ってsignを行います。
  • signする際にpinentryを利用して、鍵自体にかかっているパスワード(passphrase)を入力します。
  • pinentryにはx11やwaylandのGUIを利用するものとttyを利用するものがあります。
  • ttyを使うものは、TUI持つアプリと衝突して表示を壊すことがあります。
  • ではGUIを使うpinentryのみを使っておけばよいかと思うかもしれませんが、
    • GUIのない環境からもログインすることが増えました。
      • androidタブレットのtermuxなどです
    • GUIをsshなどを介して転送するのは結構遅くて気になるのでterminalですむならそのほうが良いです。
  • zellijのfloating windowにpinentryのプロンプトを表示すれば、terminal stateを壊さず、かつGUIなしでいい感じに入力できる。

ってことです

筆者は半年ほどtmuxをカスタマイズしながら利用していました。別にtmuxに全く不満はないのですが、zellijに乗り換えたのは、もしかしたらzellijのほうが他人にお勧めしやすいかなと思ったからです。

  • 画面を見ただけで操作がわかりやすい
    • tmuxは(素では)操作をすべて覚えてなんぼのつくり
    • zellijはある程度の操作を画面上に表示する
  • 開発言語が新し目で読みやすい
    • tmuxはCですが、
    • zellijはRustです。ビルド環境がばらけにくく、高級な機能もあるため、Cに慣れてなくて読みやすいです(たぶん・・・)。
  • ドキュメントが読みやすい
    • tmuxはでけえマニュアル(tmux(1))が出てきてグワーッてなりますが、
    • zellijはRustのライブラリ/ツールでよく見るフォーマットのドキュメントサイトがあって読みやすい

ということで、zellijも使いこなせるようになっておこうかと思った次第です。
前の記事で行ったようなカスタマイズがzellijにもないと辛かったため、こうして作成しました。

Zellijとは

https://github.com/zellij-org/zellij

tmuxと同じくterminal multiplexer/session managerです。

tmuxと違って

  • 設定ファイルがkdl
    • yamlとかtomlに似たデータフォーマットだと思えばよいです。
    • 構文的にはhclが近いです。
    • tmuxの設定ファイルはshell script的なものだったのでだいぶ違います。
    • (たぶん)演算や分岐を任意にできませんが、その分どこ見たら何書いてあるか把握しやすいです。
  • デフォルトのコピーモードが画面の内容を一時ファイルにダンプして$EDITORもしくは$VISUALで編集するものである
    • 設定でエディターは決められます。
    • 設定しないと$EDITOR or $VISUALで、それらが空の場合はvimにフォールバックします。
    • 普段使いのneovimの設定だと起動がちょっと遅いので軽量版設定詰めようかな・・・みたいな気持ちになってしまいますね。
  • 分割されたpaneが小さくなると自動的にstackされる
  • wasmでプラグインが書ける(!)
  • ブラウザからsessionに接続できる(!)
  • tmuxのhookにあたるものがない(っぽい)
  • zellij runで新しいpaneでコマンドを実行するとき、tmux popup-eオプションのような環境変数を渡すオプションがない

機能的な差は#376で一覧になっています。
触ってると結構tmuxとは違ってて全く同じことができるわけでもないみたいですが、よくできてておもろくてすごいです。

設計方針(前回との重複あり)

  • zellij run --session=${session_name} --floating -- ${command}でfloating modeのpaneを表示し、それのpty(pseudo-teletypewriter=ターミナルのデバイス)をpinentryにコントロールさせる
    • zellij runで新しいpaneで${command}を実行します。
    • floating modeはtmux popupみたいなものだと思えばよいです。実際にはいろいろ挙動が違いそうです。
    • session内から実行する場合は--sessionオプションは不要です
    • 試してみたところ表示されたterminalには(当然ではありますが)個別のptyが振られていました。
    • 前の記事で述べた通り、pinentry-cursesはttyのコントロールをとって画面を表示します
  • floating windowの中で、tty(1)を実行して、その結果をpinentry呼び出し元に送る
    • floating windowのttyのデバイスパスを取得するには、その中でtty(1)実行するのが最も手軽です。
    • pinentryの呼び出しそのものは、floating window内で行われることはないと思ってよいです。
      • gpg-agentを呼び出すのは既存のターミナルで表示しているアプリであるからです。
  • pinentryのstdinトラップして取得したttyをpinentryのttyとして指定する。
    • pinentryAssuan protocolでpinentry呼び出し元 <--> pientryのIPCを行います。
      • これでタイトルとか、プロンプトに表示されるメッセージとか、キャンセルボタンを表示するかとか、リトライ回数とかを制御します。
    • 基本的にstdin/stdoutを介して行います。
    • GPG_TTYを設定しておくと、pinentryはAssuan protocolでそのttyをコントロールする設定を行います。
      • 常に設定しておけと言われていますね。
    • GPG_TTYはgpg-agentを呼び出す環境のものが使われるため、ラッパースクリプトや、pinentry実装内でGPT_TTYの設定を上書きしても意味がありません。
    • 現状stdinをトラップして差し替えるのが最も簡単で堅牢です。
  • PINENTRY_USER_DATAのフォーマットをZELLIJ_POPUP:$(which zellij):${ZELLIJ_SESSION_NAME}とする
    • documentに示される通りこの環境変数はgpg-agentを経由しても渡されることが保証されています。
    • ZELLIJ_POPUP: この値を参照して呼び出すべきpinentry実装をスイッチするようにラッパースクリプトを組みます。
    • $(which zellij): zellijが標準的なパスにない時に必要。筆者は何も考えずcargo installで入れているので必須です。
    • ${ZELLIJ_SESSION_NAME}: セッション外から呼びす場合は指定が必要です。
  • 最大限、前回のtmux版のソースコードを再利用する
    • ほぼ同じことしかしないです。

実装

共通部分を切り出す

前回記事実装の流用可能な部分を切り出します。

  • fifo(named pipe)2つ(tty, done)生成する。
    • tty: これを通じてfloating windowからpinentry呼び出し元にtty(1)の結果を送る
    • done: floating window内のコマンドを任意のタイミングまでブロックさせるために使う。
      • pinentry成功時などに、呼び出し元が書き込む。
  • floating window(popup)表示する
    • pinentry終了までfloating windowを表示し続けるため、done fifoの読み込みでブロックしておく。
  • tty取得してfifoに送る
  • stdinをトラップしてOPTION ttyname=を差し替える
  • pinentry終了後、done fifoに書き込みを行って終了させる。

までは共通です。

floating windowを表示するコマンドがそれぞれ別のものになります。

popup.go
package popup

import (
    "bufio"
    "context"
    "errors"
    "fmt"
    "log/slog"
    "os"
    "os/exec"
    "path/filepath"
    "strings"
    "sync"
    "syscall"
    "time"
)

func CallPinentry(
    ctx context.Context,
    logger *slog.Logger,
    tempdir string,
    buildPopUpCmd func(ttyFifo, doneFifo string) (cmd string, args []string),
    validateTtyStr func(string) (string, error),
    pinentryPath string,
    pinentryArgs []string,
) (err error) {
    ttyFifo := filepath.Join(tempdir, "tty")
    doneFifo := filepath.Join(tempdir, "done")

    for _, s := range []string{ttyFifo, doneFifo} {
        err = syscall.Mknod(s, syscall.S_IFIFO|0o600, 0)
        if err != nil {
            panic(err)
        }
    }

    logger.Debug("tty fifo created")

    popupCmdPath, popupArgs := buildPopUpCmd(ttyFifo, doneFifo)

    // Launch tmux popup in background
    popupCmd := exec.CommandContext(
        ctx,
        popupCmdPath, popupArgs...,
    )
    popupCmd.Cancel = func() error {
        return popupCmd.Process.Signal(syscall.SIGTERM)
    }
    go func() {
        <-ctx.Done()
        popupCmd.Process.Kill()
    }()

    logger.Debug("popup starting")
    err = popupCmd.Start()
    if err != nil {
        return fmt.Errorf("popup failed: %w", err)
    }
    defer func() {
        logger.Debug("waiting to done fifo")
        done, err := os.OpenFile(doneFifo, os.O_RDWR, 0)
        if err != nil {
            panic(err)
        }
        defer done.Close()
        done.SetWriteDeadline(time.Now().Add(time.Second))
        done.Write([]byte("done\n"))
    }()

    logger.Debug("opening tty fifo")
    f, err := os.OpenFile(ttyFifo, os.O_RDWR, 0)
    if err != nil {
        return fmt.Errorf("failed to open tty: %w", err)
    }
    defer f.Close()

    f.SetReadDeadline(time.Now().Add(20 * time.Second))

    scanner := bufio.NewScanner(f)

    logger.Debug("waiting tty notification")
    scanner.Scan()
    t := scanner.Text()
    if scanner.Err() != nil {
        return fmt.Errorf("scan failed: %w", scanner.Err())
    }

    targetTty, err := validateTtyStr(t)
    if err != nil {
        return err
    }

    logger.Debug("tmux popup started")

    if targetTty == "" {
        return fmt.Errorf("popup return an empty tty")
    }

    logger.Debug("got TTY from popup")

    // Run pinentry-curses with stdin interception
    cmd := exec.CommandContext(ctx, pinentryPath, pinentryArgs...)
    cmd.Cancel = func() error {
        return cmd.Process.Signal(syscall.SIGTERM)
    }

    p, err := cmd.StdinPipe()
    if err != nil {
        return err
    }

    cmd.Stdout = os.Stdout
    cmd.Stderr = os.Stderr

    err = cmd.Start()
    if err != nil {
        return fmt.Errorf("%s failed to start: %w", pinentryPath, err)
    }

    logger.Debug("pinentry-curses started")

    // Intercept stdin and replace ttyname
    var wg sync.WaitGroup
    wg.Add(1)
    go func() {
        defer wg.Done()
        defer p.Close()

        scanner := bufio.NewScanner(os.Stdin)
        for scanner.Scan() {
            line := scanner.Text()

            // Replace ttyname option with the popup's TTY
            if strings.HasPrefix(line, "OPTION ttyname=") {
                original := line
                line = "OPTION ttyname=" + targetTty
                logger.Debug("replaced ttyname", slog.String("old", original), slog.String("new", line))
            }

            logger.Debug("forwarding input", slog.String("line", line))

            _, err := p.Write([]byte(line + "\n"))
            if err != nil {
                logger.Warn("write error", slog.Any("err", err))
                break
            }
        }

        if err := scanner.Err(); err != nil {
            logger.Warn("scanner error", slog.Any("err", err))
        }
    }()

    err = cmd.Wait()
    if err != nil {
        var execErr *exec.ExitError
        if errors.As(err, &execErr) {
            err = fmt.Errorf("%v: stderr = %s", execErr, string(execErr.Stderr))
        }
    }

    logger.Debug("pinentry-curses finished", slog.Any("err", err))

    os.Stdin.Close()

    wg.Wait()

    if err != nil {
        return fmt.Errorf("pinentry-curses failed: %w", err)
    }

    return nil
}

zellij run --floating部分

zellij run --floating呼び出し部分です。

main packageです。mainはエントリポイントの呼び出し以上のことやらないようにしないと単体テストが書きにくいからやらないほうがいいですが、そもそも単体テストとか書いてないのでこんなもんでいいです。

  • tmux popupは何も指定しないと$(SHELL) -cでコマンドを実行する挙動がソース上見られ、&&やパイプがあるコマンドもなんとなくいい感じに動きますが、
  • zellij runでは見たところうまく動かなったので、shellで実行するようにします。
    • ソースは軽く追っていますがまだどこがpane実行でコマンド動かす部分なのか読めてないです。
  • 基本は$SHELLで指定されたものを使います。
    • docker containerでENTRYPOINTをシェルにした場合などは、$SHELLが設定されていないことがあるため、その場合のためにbashにフォールバックする挙動を加えておきます。
  • $PINENTRY_USER_DATAを解析してzellijのパス、zellijのセッション名を取得します。
  • #4031より、zellij runで環境変数を渡す方法がないためfifoは渡すコマンドで指定します。
    • こうするとps eでほかのユーザーからもfifoのパスなどが観測できますが、
    • os.MkdirTempで作成されたディレクトりはパーミッションが0x600であるので基本的には作成したユーザーにしか操作しえないので大丈夫でしょう。
main.go
package main

import (
    "cmp"
    "context"
    "fmt"
    "log/slog"
    "os"
    "os/signal"
    "path/filepath"
    "strings"
    "syscall"
    "time"

    "github.com/ngicks/run-in-tmux-popup/internal/popup"
)

func main() {
    ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
    defer cancel()
    ctx, stop := signal.NotifyContext(ctx, syscall.SIGINT, syscall.SIGTERM, syscall.SIGABRT)
    defer stop()

    tempdir, err := os.MkdirTemp("", "")
    if err != nil {
        panic(err)
    }
    defer func() {
        _ = os.RemoveAll(tempdir)
    }()

    logger := slog.New(slog.NewTextHandler(os.Stderr, nil))
    if os.Getenv("TMUX_POPUP_DEBUG") == "1" {
        logFile, err := os.OpenFile(
            filepath.Join(tempdir, "log.txt"),
            os.O_APPEND|os.O_CREATE|os.O_RDWR, 0o700,
        )
        if err != nil {
            panic(err)
        }
        defer logFile.Close()

        logger = slog.New(slog.NewTextHandler(logFile, &slog.HandlerOptions{Level: slog.LevelDebug}))
    }

    shellName := cmp.Or(os.Getenv("SHELL"), "bash")
    zellijPath, sessionName, _ := strings.Cut(
        strings.TrimPrefix(
            strings.TrimSpace(
                os.Getenv("PINENTRY_USER_DATA")),
            "ZELLIJ_POPUP:",
        ),
        ":",
    )
    if len(zellijPath) == 0 || len(sessionName) == 0 {
        panic(
            fmt.Errorf(
                "enviroment variable \"PINENTRY_USER_DATA\" must be"+
                    " formated as \"ZELLIJ_POPUP:zellij_path:session_name\" but is %q",
                os.Getenv("PINENTRY_USER_DATA"),
            ),
        )
    }

    err = popup.CallPinentry(
        ctx,
        logger,
        tempdir,
        func(ttyFifo, doneFifo string) (cmd string, args []string) {
            return zellijPath, []string{
                "--session=" + sessionName,
                "run",
                "--name=pinentry-curses",
                "--floating",
                "--close-on-exit",
                "--pinned=true",
                "--",
                shellName,
                "-c",
                fmt.Sprintf("echo $(tty) >> %s && read done < %s", ttyFifo, doneFifo),
            }
        },
        func(t string) (string, error) {
            return strings.TrimSpace(t), nil
        },
        "/usr/bin/pinentry-curses",
        os.Args[1:],
    )
    if err != nil {
        panic(err)
    }
}

ビルドして使えるようにする

ビルドして配置

~/.local/binに配置します。
GOBINを設定してgo installします。

GOBIN=$HOME/.local/bin go install github.com/ngicks/run-in-tmux-popup/cmd/zellij-popup-pinentry-curses@7bcf6df8dfbf57ae46e08876e79468ef19d6006d

ラッパースクリプトを書く

PINENTRY_USER_DATAに基づいてこのバイナリを呼び出すようにラッパースクリプトを書きます。

#!/bin/bash

set -Ceu

case "${PINENTRY_USER_DATA-}" in
*TTY*)
  exec pinentry-curses "$@"
  ;;
*TMUX_POPUP*)
  exec $HOME/.local/bin/tmux-popup-pinentry-curses "$@"
  ;;
*ZELLIJ_POPUP*)
  exec $HOME/.local/bin/zellij-popup-pinentry-curses "$@"
  ;;
esac

exec pinentry-qt "$@"

gpg-agent.confでラッパースクリプトを指定

私はこのスクリプトをdotfilesに突っ込んでおいてあるのでそのパスを指定しておきます。

~/.gnupg/gpg-agent.conf
pinentry-program /home/ngicks/.dotfiles/scripts/pinentry.sh

start up scriptでPINENTRY_USER_DATAを設定

~/.bashrc~/.zshrcなどから読み込まれるスクリプトで下記のように指定します。

if [ -n "${TMUX}" ]; then
  export PINENTRY_USER_DATA="TMUX_POPUP:$(which tmux):${TMUX}"
elif [ -n "${ZELLIJ}" ]; then
  export PINENTRY_USER_DATA="ZELLIJ_POPUP:$(which zellij):${ZELLIJ_SESSION_NAME}"
fi

完成!

敗ということで、gpg-agent経由で呼びだれるとこういう見た目になります。

例によって余白がいっぱいありますが筆者は気にしません。

Caveats

  • 既に開いている非表示状態のfloating windowがあるとそれも表示されてしまう。

おわりに

試しにclaude codeにHow can I use zellij floating windows as pinentry front end?と聞いてもここまでのものは出てこなかったのでやって意味なかったわけでもないかなと思います。

GitHubで編集を提案

Discussion

ngicksngicks

複数のclientからアタッチされてるzellijでこれを呼び出すと最初にアタッチした人のフォーカス位置でフローティングウィンドウが出て困ることを発見。tabidなど指定するように変えねば…

ngicksngicks

ぱっと見る限りコマンドラインからpaneやtabを指定する方法ないですね。
issueをたてました
https://github.com/zellij-org/zellij/issues/4474

環境変数にpane idはあるのでそこから所属するタブを割り出してそこに表示する方法は取れるかとおもいます。セッション内からzellij run --floatingした場合はそのタブに表示されますから、タブを指定する方法は普通にあるはずですし