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를 쓰고 있는데, 대부분 -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 
      |          ^~~~~~~~~~~~
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 헤더를 include하는데, 그러면 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 구현들처럼 키보드를 그랩하지 않는다. GTK3 헬퍼는 입력 중에 다른 클라이언트가 패스프레이즈를 읽지 못하도록 gdk_seat_grab을 호출하지만, 내가 보기에는 GTK4가 그 인터페이스를 없앴고 Wayland도 클라이언트가 키보드를 그랩하는 것을 전혀 허용하지 않는 것으로 보여, X11 없이는 이를 수행할 이식성 있는 방법이 없다. 따라서 GNOME_SSH_ASKPASS_GRAB_SERVERGNOME_SSH_ASKPASS_GRAB_POINTER 변수도 효과가 없다.

코드는 tty.fail에 있으며 GitHub에도 미러되어 있고, 태그된 각 릴리스마다 아키텍처별 리눅스용 프리빌드 바이너리를 제공한다. 사용하려면 바이너리를 PATH 어딘가에 넣고 SSH_ASKPASS가 이를 가리키도록 하면 된다. 터미널에서라면 ~/.profile이나 셸 시작 파일(예를 들어 나와 같은 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.conf에 절대 경로를 사용한 단순한 KEY=VALUE 형식으로 넣으면 되는데, 이 파일은 ~를 확장하지도 셸을 실행하지도 않기 때문이다. 로그아웃 후 다시 로그인하면, 터미널 없이 패스프레이즈가 필요한 다음 ssh-addgit pull, go get에서 다이얼로그가 뜬다.

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

댓글