A GTK4 ssh-askpass in Zig

Marius

Zig로 만든 GTK4 ssh-askpass

Gentoo 트리의 모든 ssh-askpass는 X11이나 전체 KDE 스택을 끌어오기 때문에, 저는 Zig 0.16과 GTK4로 직접 만들었습니다. 빌드에서 X가 끼어들지 않도록 손수 작성한 바인딩을 사용했습니다.

Zig로 만든 GTK4 ssh-askpass

저는 노트북에서 hardened Gentoo를 사용하고 있으며, 대부분 -sk 키를 쓰기 때문에 평소에는 ssh-askpass를 쓸 일이 거의 없습니다. 하지만 반드시 필요한 경우가 한 가지 있습니다. 프로그램이 일반 ED25519 키의 SSH 패스프레이즈를 요구하는데 읽어올 터미널이 없을 때입니다. 대표적인 경우는 TTY 없이 실행되는 빌드 중에 go get이나 go 툴체인 전반이 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가 필요합니다. 이번에는 GTK2X 지원으로 빌드된 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을 호출하고 소스에서는 X 전용 GDK 헤더인 gdk/gdkx.h를 include하므로, 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 헤더가 딸려 들어옵니다. C 라이브러리를 호출하는 가장 당연한 방법인 Zig@cImport 역시 똑같이 동작합니다. 정확히 그 헤더들을 끌어오기 때문입니다. 그래서 ssh-askpass-zigtk@cImport를 전혀 사용하지 않습니다. src/gtk.zig는 프로그램이 호출하는 서른 개 남짓의 GTKGLib 함수를 평범한 extern 프로토타입으로 직접 선언합니다:

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 키심은 gdk/gdkkeysyms.h에서 가져오는 대신 0xff1b로 하드코딩되어 있습니다.

GTKGObject 위에 구축되어 있으며, GObject는 부모 구조체를 자식의 첫 번째 멤버로 두는 방식으로 단일 상속을 구현합니다. 따라서 윈도우, 박스, 레이블, 패스워드 입력창, 버튼은 모두 ABI 경계에서 GtkWidget *와 레이아웃 호환성을 갖습니다. Zig 쪽에서는 하나의 opaque {} 타입이 이 모든 것을 대신하며, 모든 위젯 함수가 동일한 *Widget을 받고 반환합니다. C ABI가 이미 평탄화해 둔 것을 굳이 래퍼 타입 계층으로 모델링할 필요가 없습니다.

GTK를 건드리지 않는 부분, 즉 SSH_ASKPASS_PROMPT를 다이얼로그 타입으로 매핑하는 부분과 GNOME_SSH_ASKPASS_*_COLOR 변수를 파싱하는 부분은 단위 테스트와 함께 src/root.zig에 들어 있어, 디스플레이도 GTK도 없이 zig build test로 실행할 수 있습니다. 색상 변경은 작은 CSS 프로바이더를 통해 처리하는데, GTK4에서 gtk_widget_modify_fg_bg가 제거되었기 때문입니다.

크로스 컴파일

바인딩이 손수 작성된 extern이고 빌드에 GTK 헤더가 전혀 들어오지 않으므로, Zig는 해당 타깃용 GTK4 툴체인이 없어도 모든 Linux 아키텍처용 바이너리를 크로스 컴파일할 수 있습니다. 링크 시점에 유일하게 부족한 것은 GTK4 공유 라이브러리 자체뿐이며, 이는 -Dgtk-stub으로 해결됩니다. 이 옵션은 내보내는 심볼이 모두 no-op인 아주 작은 스텁 libgtk-4.so.1을 빌드해 실행 파일을 그 스텁에 링크하고, 실제 GTK4는 런타임에 타깃의 것으로 해석되도록 합니다. 릴리스 워크플로는 이를 이용해 x86_64, aarch64, armv7, riscv64, powerpc64le, i386, loongarch64, s390x용 바이너리를 한 머신에서 만들어 냅니다. 그 머신에는 나머지 일곱 아키텍처용 GTK4가 전혀 설치되어 있지 않습니다.

참고: ssh-askpass-zigtk는 다른 askpass 구현들이 보통 하는 것처럼 키보드를 grab하지 않습니다. GTK3 헬퍼는 입력 중인 패스프레이즈를 다른 클라이언트가 읽지 못하도록 gdk_seat_grab을 호출하지만, 제가 보기에는 GTK4에서 해당 인터페이스가 사라졌고 Wayland 역시 클라이언트가 키보드를 grab하는 것을 허용하지 않는 것으로 보입니다. 따라서 X11 없이는 이를 수행할 이식성 있는 방법이 없습니다. 그래서 GNOME_SSH_ASKPASS_GRAB_SERVERGNOME_SSH_ASKPASS_GRAB_POINTER 변수도 효과가 없습니다.

코드는 tty.fail에 있으며 GitHub에도 미러되어 있고, 태그가 붙은 각 릴리스마다 아키텍처별 Linux 바이너리가 미리 빌드되어 제공됩니다. 사용하려면 바이너리를 PATH 어딘가에 두고 SSH_ASKPASS가 그곳을 가리키도록 하면 됩니다. 터미널에서 사용한다면 ~/.profile이나 셸의 시작 파일(예를 들어 저처럼 Zsh를 쓰신다면 같은 Zsh 사용자 분들을 위한 ~/.zshrc)에 두 줄을 추가하면 됩니다:

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

SSH_ASKPASS_REQUIRE=preferOpenSSH 8.4부터 도입된 옵션으로, 그래픽 세션이 존재하는 한 터미널이 있더라도 다이얼로그를 사용하도록 OpenSSH에 지시합니다. systemd 사용자 세션에서는 동일한 두 변수를 절대 경로를 사용해 ~/.config/environment.d/ssh-askpass.confKEY=VALUE 형식의 평범한 줄로 넣어 주면 됩니다. 이 파일은 ~를 확장하지도 않고 셸을 거치지도 않기 때문입니다. 로그아웃 후 다시 로그인하면, 터미널 없이 패스프레이즈가 필요한 다음 ssh-addgit pull, go get에서 다이얼로그가 표시됩니다.

원문은 Marius님이 에 게재했습니다.

이 글은 muse-spark-1.2-contributor 모델을 사용해 번역했습니다.