A GTK4 ssh-askpass in Zig

Marius

用 Zig 编写的 GTK4 ssh-askpass

原文由 Marius 发布,订阅该博客

Gentoo 软件仓库中的每一个 ssh-askpass 都会引入 X11 或整套 KDE 依赖,所以我用 Zig 0.16 和 GTK4 自己写了一个,并手写了绑定,让构建过程完全不依赖 X

用 Zig 编写的 GTK4 ssh-askpass

我的笔记本上运行的是 hardened Gentoo,大多数时候我根本用不到 ssh-askpass,因为多数系统我都在用 -sk 密钥。但有一类场景确实需要它,那就是某个程序需要普通 ED25519 密钥的 SSH 口令,却没有可供读取的终端。最典型的情况是 go get,或是整个 go 工具链,在没有 TTY 的构建过程中通过 SSH 拉取私有模块。OpenSSH 无法在管道上弹出提示,于是会执行 SSH_ASKPASS 指向的程序,把口令提示放到窗口里。多年来我一直没有为此安装任何东西,只能想办法绕过这些场景。主要原因就在于 GentooPortage 所提供的选项:

 ~ emerge -s ssh-askpass

[ Results for search key : ssh-askpass ]
Searching...

*  kde-plasma/ksshaskpass
      Latest version available: 6.6.6
      Latest version installed: [ Not Installed ]
      Size of files: 32 KiB
      Homepage:      https://kde.org/plasma-desktop https://invent.kde.org/plasma/ksshaskpass
      Description:   Implementation of ssh-askpass with KDE Wallet integration
      License:       GPL-2

*  lxqt-base/lxqt-openssh-askpass
      Latest version available: 2.4.0
      Latest version installed: [ Not Installed ]
      Size of files: 19 KiB
      Homepage:      https://lxqt-project.org/ https://github.com/lxqt/lxqt-openssh-askpass/
      Description:   LXQt OpenSSH user password prompt tool
      License:       LGPL-2.1 LGPL-2.1+

*  net-misc/gnome-ssh-askpass
      Latest version available: 10.0_p1
      Latest version installed: [ Not Installed ]
      Size of files: 1,927 KiB
      Homepage:      https://www.openssh.com/
      Description:   GTK-based passphrase dialog for use with OpenSSH
      License:       BSD GPL-2

*  net-misc/ssh-askpass-fullscreen
      Latest version available: 1.2
      Latest version installed: [ Not Installed ]
      Size of files: 81 KiB
      Homepage:      https://github.com/atj/ssh-askpass-fullscreen
      Description:   A small SSH Askpass replacement written with GTK2
      License:       GPL-2+

*  net-misc/x11-ssh-askpass
      Latest version available: 1.2.4.1-r2
      Latest version installed: [ Not Installed ]
      Size of files: 30 KiB
      Homepage:      https://github.com/sigmavirus24/x11-ssh-askpass
      Description:   X11-based passphrase dialog for use with OpenSSH
      License:       HPND

[ Applications found : 5 ]

这些包各有至少一个让我不想接受的缺点。我的系统全局启用了 -X USE 标记,所以任何需要 X11 的包在一开始就被排除了。五个包中,只有 kde-plasma/ksshaskpass 完全不依赖 X11,本应是显而易见的选择,但问题在于它会带来的一大堆其他东西。作为 Sway 用户,我并不想为了偶尔输入一次口令就在机器上装一整套 KDE,而安装 ksshaskpass 正是如此:

~ # emerge -a kde-plasma/ksshaskpass

These are the packages that would be merged, in order:

Calculating dependencies... done!
Dependency resolution took 1.18 s (backtrack: 0/20).

[ebuild  N     ] kde-frameworks/kf-env-6
[ebuild  N     ] dev-libs/plasma-wayland-protocols-1.21.0  USE="-test"
[ebuild  N     ] dev-libs/qtkeychain-0.16.0  USE="-keyring -test"
[ebuild  N     ] kde-frameworks/ksecretd-services-6.27.0  USE="(-systemd)"
[ebuild  N     ] kde-frameworks/breeze-icons-6.27.0  USE="-test"
[ebuild  N     ] kde-frameworks/ki18n-6.27.0  USE="-debug -test" PYTHON_SINGLE_TARGET="python3_14 -python3_12 -python3_13"
[ebuild  N     ] kde-frameworks/kconfig-6.27.0  USE="dbus -debug -qml -test"
[ebuild  N     ] kde-frameworks/kcoreaddons-6.27.0  USE="dbus -debug -test"
[ebuild  N     ] kde-frameworks/kwidgetsaddons-6.27.0  USE="-debug -designer -test"
[ebuild  N     ] kde-frameworks/kdbusaddons-6.27.0  USE="-X -debug -test"
[ebuild  N     ] kde-frameworks/kwindowsystem-6.27.0  USE="wayland -X -debug -test"
[ebuild  N     ] app-crypt/qca-2.3.10-r1  USE="ssl -botan -debug -doc -examples -gcrypt -gpg -logger -nss -pkcs11 -sasl -softstore -test"
[ebuild  N     ] kde-frameworks/karchive-6.27.0  USE="crypt zstd -debug -test"
[ebuild  N     ] kde-frameworks/kguiaddons-6.27.0  USE="dbus wayland -X -debug -test"
[ebuild  N     ] kde-frameworks/kdoctools-6.27.0  USE="nls -debug -test"
[ebuild  N     ] kde-frameworks/kcolorscheme-6.27.0  USE="-debug"
[ebuild  N     ] kde-frameworks/kcrash-6.27.0  USE="-X -debug -test"
[ebuild  N     ] kde-frameworks/knotifications-6.27.0  USE="-debug"
[ebuild  N     ] kde-frameworks/kservice-6.27.0  USE="man -debug -test"
[ebuild  N     ] kde-frameworks/kwallet-6.27.0  USE="-debug -minimal -test"
[ebuild  N     ] kde-frameworks/kwallet-runtime-6.27.0  USE="(keyring) (legacy-kwallet) man -X -debug -gpg -test"
[ebuild  N     ] kde-plasma/ksshaskpass-6.6.6  USE="-debug"

Would you like to merge these packages? [Yes/No]

lxqt-base/lxqt-openssh-askpass 是下一个,它直接就需要 X。除此之外,它还会引入几个 KDE 框架包以及一个带 X 支持编译的 Qt,这与我系统上已有的、以 -X 编译的 dev-qt/qtbase 冲突,导致 Portage 报出槽位冲突:

~ # emerge -a lxqt-base/lxqt-openssh-askpass

These are the packages that would be merged, in order:

Calculating dependencies... done!
Dependency resolution took 3.10 s (backtrack: 0/20).

[ebuild   R    ] x11-libs/libxkbcommon-1.13.2  USE="X*"
[ebuild  N     ] dev-util/lxqt-build-tools-2.4.0
[ebuild  N     ] x11-libs/xcb-util-renderutil-0.3.10  ABI_X86="(64) -32 (-x32)"
[ebuild  N     ] x11-libs/libXScrnSaver-1.2.5  USE="-doc" ABI_X86="(64) -32 (-x32)"
[ebuild  N     ] dev-libs/plasma-wayland-protocols-1.21.0  USE="-test"
[ebuild  N     ] x11-libs/xcb-util-wm-0.4.2  ABI_X86="(64) -32 (-x32)"
[ebuild  N     ] x11-libs/xcb-util-0.4.1  ABI_X86="(64) -32 (-x32)"
[ebuild  N     ] x11-libs/xcb-util-image-0.4.1  ABI_X86="(64) -32 (-x32)"
[ebuild  N     ] x11-libs/xcb-util-cursor-0.1.6  ABI_X86="(64) -32 (-x32)"
[ebuild   R    ] dev-qt/qtbase-6.11.1  USE="X*"
[ebuild  N     ] sys-auth/polkit-qt-0.201.1
[ebuild  N     ] dev-libs/libqtxdg-4.4.0  USE="-test"
[ebuild  N     ] kde-frameworks/kf-env-6
[ebuild  N     ] kde-frameworks/kwindowsystem-6.27.0  USE="X wayland -debug -test"
[ebuild  N     ] lxqt-base/liblxqt-2.4.0  USE="backlight"
[ebuild  N     ] lxqt-base/lxqt-openssh-askpass-2.4.0

!!! Multiple package instances within a single package slot have been pulled
!!! into the dependency graph, resulting in a slot conflict:

dev-qt/qtbase:6
  (dev-qt/qtbase-6.11.1:6/6.11.1::gentoo, installed) USE="concurrent dbus gui icu libinput libproxy network nls opengl sql sqlite ssl syslog udev vulkan wayland widgets xml (zstd) -X -accessibility -brotli -cups -custom-cflags -eglfs -evdev -gles2-only -gssapi -gtk -io-uring (-journald) -mysql -oci8 -odbc -postgres -renderdoc -sctp -test -tslib" ABI_X86="(64)"
  (dev-qt/qtbase-6.11.1:6/6.11.1::gentoo, ebuild scheduled for merge) USE="X concurrent dbus gui icu libinput libproxy network nls opengl sql sqlite ssl syslog udev vulkan wayland widgets xml (zstd) -accessibility -brotli -cups -custom-cflags -eglfs -evdev -gles2-only -gssapi -gtk -io-uring (-journald) -mysql -oci8 -odbc -postgres -renderdoc -sctp -test -tslib" ABI_X86="(64)"

The following USE changes are necessary to proceed:
# required by lxqt-base/liblxqt-2.4.0::gentoo
>=kde-frameworks/kwindowsystem-6.27.0 X
# required by kde-frameworks/kwindowsystem-6.27.0::gentoo[X]
>=dev-qt/qtbase-6.11.1 X
# required by dev-qt/qtbase-6.11.1::gentoo[gui]
>=x11-libs/libxkbcommon-1.13.2 X

net-misc/ssh-askpass-fullscreen 同样需要 X,这次是通过 GTK2 和一个带 X 支持编译的 Cairo:

~ # emerge -a net-misc/ssh-askpass-fullscreen

These are the packages that would be merged, in order:

Calculating dependencies... done!
Dependency resolution took 2.98 s (backtrack: 0/20).

[ebuild   R    ] x11-libs/cairo-1.18.4-r1  USE="X*"
[ebuild  N     ] x11-libs/libXcomposite-0.4.7  USE="-doc" ABI_X86="(64) -32 (-x32)"
[ebuild  N     ] x11-libs/libXcursor-1.2.3  USE="-doc" ABI_X86="(64) -32 (-x32)"
[ebuild  N     ] x11-libs/libXdamage-1.1.7  ABI_X86="(64) -32 (-x32)"
[ebuild  N     ] x11-libs/libXi-1.8.3  USE="-doc" ABI_X86="(64) -32 (-x32)"
[ebuild  N     ] x11-themes/gnome-themes-standard-3.28-r1
[ebuild  NS    ] x11-libs/gtk+-2.24.33-r3 [3.24.52]
[ebuild  N     ] x11-themes/gtk-engines-adwaita-3.28-r1  ABI_X86="(64) -32 (-x32)"
[ebuild  N     ] net-misc/ssh-askpass-fullscreen-1.2

The following USE changes are necessary to proceed:
# required by x11-libs/gtk+-2.24.33-r3::gentoo
# required by x11-themes/gtk-engines-adwaita-3.28-r1::gentoo
>=x11-libs/cairo-1.18.4-r1 X

Would you like to add these changes to your config files? [Yes/No]

net-misc/x11-ssh-askpass 顾名思义就是基于 X11 的,这倒不意外,而且它编译时还需要古老的 imake 构建系统,也就是 x11-misc/xorg-cf-filesx11-misc/imake

~ # emerge -a net-misc/x11-ssh-askpass

These are the packages that would be merged, in order:

Calculating dependencies... done!
Dependency resolution took 1.00 s (backtrack: 0/20).

[ebuild  N     ] app-text/rman-3.2-r2
[ebuild  N     ] x11-misc/xorg-cf-files-1.0.9
[ebuild  N     ] x11-misc/imake-1.0.10
[ebuild  N     ] net-misc/x11-ssh-askpass-1.2.4.1-r2

Would you like to merge these packages? [Yes/No]

只剩下 net-misc/gnome-ssh-askpass。乍看之下,它似乎是唯一完全不需要 X 的选项,但事实并非如此。它实际上需要 X11,而 ebuild 在这方面似乎有缺陷。构建时会调用 pkg-config --libs gtk+-3.0 x11,源码中也包含了 gdk/gdkx.h 这个仅限 X 的 GDK 头文件,所以在以无 X 方式编译的系统上根本无法构建:

~ # emerge -a net-misc/gnome-ssh-askpass

These are the packages that would be merged, in order:

Calculating dependencies... done!
Dependency resolution took 0.89 s (backtrack: 0/20).

[ebuild  N    ~] net-misc/gnome-ssh-askpass-10.0_p1  USE="-verify-sig"

Would you like to merge these packages? [Yes/No] Yes
>>> Verifying ebuild manifests
>>> Emerging (1 of 1) net-misc/gnome-ssh-askpass-10.0_p1::guru
>>> Failed to emerge net-misc/gnome-ssh-askpass-10.0_p1, Log file:
>>>  '/var/tmp/portage/net-misc/gnome-ssh-askpass-10.0_p1/temp/build.log'
>>> Jobs: 0 of 1 complete, 1 failed                                     Load avg: 0.79, 0.57, 0.61
 * Package:    net-misc/gnome-ssh-askpass-10.0_p1:0
 * Repository: guru
 * USE:        abi_x86_64 amd64 elibc_glibc kernel_linux
 * FEATURES:   network-sandbox preserve-libs sandbox selinux sesandbox userpriv usersandbox
>>> Unpacking source...
>>> Unpacking 'openssh-10.0p1.tar.gz' to /var/tmp/portage/net-misc/gnome-ssh-askpass-10.0_p1/work
>>> Source unpacked in /var/tmp/portage/net-misc/gnome-ssh-askpass-10.0_p1/work
>>> Preparing source in /var/tmp/portage/net-misc/gnome-ssh-askpass-10.0_p1/work/openssh-10.0p1/contrib ...
>>> Source prepared.
>>> Configuring source in /var/tmp/portage/net-misc/gnome-ssh-askpass-10.0_p1/work/openssh-10.0p1/contrib ...
>>> Source configured.
>>> Compiling source in /var/tmp/portage/net-misc/gnome-ssh-askpass-10.0_p1/work/openssh-10.0p1/contrib ...
make -j17 -l15.95 gnome-ssh-askpass3
x86_64-pc-linux-gnu-gcc -O2 -pipe -march=native `pkg-config --cflags gtk+-3.0` \
        gnome-ssh-askpass3.c -o gnome-ssh-askpass3 \
        `pkg-config --libs gtk+-3.0 x11`
gnome-ssh-askpass3.c:62:10: fatal error: gdk/gdkx.h: No such file or directory
   62 | #include <gdk/gdkx.h>
      |          ^~~~~~~~~~~~ 
compilation terminated.
make: *** [Makefile:17: gnome-ssh-askpass3] Error 1
 * ERROR: net-misc/gnome-ssh-askpass-10.0_p1::guru failed (compile phase):
 *   emake failed

到这里,我彻底放弃了打包好的选项。即便先不谈 X11 的问题,这些包最多也只用到了 GTK2GTK3。恰好我早就想用 GTK4 做点东西,于是与其去修补那些大多本来就是用 C 写的已有实现,不如直接用 Zig 0.16 和 GTK4 自己写一个,并把它命名为 ssh-askpass-zigtk

避开 X11

GTK 辅助程序在我的系统上构建失败的原因在于头文件。常规调用 GTK 的方式会包含 GTK4 头文件,进而引入 GDK,而 GDK 在大多数安装中仍会提供 gdk/gdkx.h,于是无论你是否需要,X11 头文件都会被拉进来。Zig 中调用 C 库最直接的方式 @cImport 也会做同样的事,因为它会原样引入这些头文件。所以 ssh-askpass-zigtk 完全没有使用 @cImportsrc/gtk.zig 以普通的 extern 原型手写声明了程序会调用的三十多个 GTKGLib 函数:

pub const Widget = opaque {};

pub extern fn gtk_window_new() *Widget;
pub extern fn gtk_password_entry_new() *Widget;
pub extern fn gtk_editable_get_text(editable: *Widget) [*:0]const u8;
pub extern fn gtk_button_new_with_label(label: [*:0]const u8) *Widget;

这个文件里没有任何符号来自 gdk/gdkx.hX11/Xlib.h,因此编译器从头到尾都不会见到 X 头文件,生成的二进制也可以在以无 X11 方式编译的 GTK4 上正常构建和运行。它唯一需要的、与 X 相关的值——Escape 键的键码——被硬编码为 0xff1b,而不是从 gdk/gdkkeysyms.h 中引入。

GTK 构建于 GObject 之上,后者通过将父结构体作为子结构体的第一个成员来实现单继承,因此窗口、容器、标签、密码输入框和按钮在 ABI 层面都与 GtkWidget * 布局兼容。在 Zig 这一侧,用同一个 opaque {} 类型来代表所有控件,所有控件函数都接收和返回同样的 *Widget,而无需为 C ABI 本就已扁平化的层级再封装一套包装类型。

不涉及 GTK 的部分——将 SSH_ASKPASS_PROMPT 映射为对话框类型、以及对 GNOME_SSH_ASKPASS_*_COLOR 变量的解析——则放在 src/root.zig 中,并配有单元测试,因此可以在无显示、无 GTK 的环境下通过 zig build test 直接运行。重新着色通过一个小的 CSS provider 实现,因为 GTK4 已经移除了 gtk_widget_modify_fg_bg

交叉编译

由于绑定全是手写的 extern,且构建过程中完全不引入 GTK 头文件,Zig 可以在无需为目标平台准备 GTK4 工具链的情况下,为任意 Linux 架构交叉编译二进制。链接时唯一缺少的是 GTK4 共享库本身,而 -Dgtk-stub 解决了这个问题:它会构建一个导出符号全为空操作的微型桩 libgtk-4.so.1,让可执行文件先链接到它,运行时再由目标系统上真正的 GTK4 来解析。发布流程正是利用这一点,在一台机器上为 x86_64aarch64armv7riscv64powerpc64lei386loongarch64s390x 生成二进制,而这台机器上并没有为其余七个架构安装 GTK4

注意: ssh-askpass-zigtk 不会像其他 askpass 实现通常那样抢占键盘。GTK3 的辅助程序会调用 gdk_seat_grab,让其他客户端在你输入口令时无法窃取,但据我观察,GTK4 已经移除了该接口,而且我认为 Wayland 根本不允许客户端抢占键盘,因此在不依赖 X11 的情况下没有可移植的实现方式。所以,GNOME_SSH_ASKPASS_GRAB_SERVERGNOME_SSH_ASKPASS_GRAB_POINTER 这两个变量也不会起任何作用。

代码托管在 tty.fail 上,并镜像到 GitHub,每个带标签的版本都会为各架构提供预编译的 Linux 二进制。要使用它,只需将二进制放到 PATH 中的某个位置,并将 SSH_ASKPASS 指向它。对于终端来说,这意味着在 ~/.profile 或 shell 启动文件(例如,我的 同样使用 Zsh 的用户可在 ~/.zshrc 中)加入两行:

export SSH_ASKPASS=~/.local/bin/ssh-askpass-zigtk
export SSH_ASKPASS_REQUIRE=prefer

OpenSSH 8.4 开始,SSH_ASKPASS_REQUIRE=prefer 会告诉 OpenSSH,只要存在图形会话,即使有可用终端也优先使用对话框。在 systemd 用户会话中,同样的两个变量需以纯 KEY=VALUE 形式、用绝对路径写入 ~/.config/environment.d/ssh-askpass.conf,因为该文件既不会展开 ~,也不会经过 shell 解析。注销后重新登录,下一次需要口令但没有终端的 ssh-addgit 拉取或 go get 就会弹出该对话框。

本文章由 muse-spark-1.2-contributor 进行翻译

评论