Files
DodoSSH/build/linux/dodossh.spec
T
jaap-jan 1bf79c7c26
ci / build and test (push) Successful in 3m1s
ci / android head (push) Successful in 3m39s
ci / desktop nightly (push) Successful in 1m7s
ci / api image (push) Successful in 22s
Package the Linux desktop head as an RPM, and publish a nightly one
The third desktop platform shipped as nothing: a checkout somebody built.
VelopackUpdateChannel already named the two platforms it serves rather than
negating the one it does not, so that a Linux package would be an addition
there rather than something that had silently half-happened. This is it, and
it is deliberately not a Velopack channel.

There is no updater in the package at all. On this platform dnf is already an
updater the user trusts, audits and controls, and a second one answering to
this project instead is the capability ADR 0013 rule 4 spends a page keeping
unsettable. The cost is real and is written down rather than glossed: nothing
tells a Linux user that a newer build exists, because there is no repository
to tell them.

Rule 3 is untouched. The build job packages the release-shaped RPM on every
main build and every tag and uploads nothing, exactly as it does for Windows
and macOS; the desktop job packages the nightly-shaped one and puts it on the
rolling nightly-desktop release beside the Windows feed. Two RPMs with
different names owning different paths cannot replace one another, so rule 9's
separations come almost free here — the profile split still belongs to the
build, through DodoChannel.

vpk verifies that what it packs actually calls VelopackApp.Build().Run(), and
nothing here does that for us, so scripts/ci-linux-rpm.sh makes its own
assertions: it asks the finished package what it requires, what it provides,
what it holds and what modes those carry. Two of those checks exist because
the first realistic payload tripped them.

  - .NET's libmscordbi.so links libmscordaccore.so. With bundled provides
    excluded — which they must be, or this package would advertise libcoreclr
    to the whole system — that produced a dependency no host on earth can
    satisfy. The requires exclusion is computed from the payload rather than
    listed, because a list of Microsoft's internal file names goes stale in
    the direction that yields an uninstallable package.

  - cp -a inherits the publish directory's modes, and off the wrong filesystem
    that meant a world-writable /opt/DodoSSH: any local account able to
    replace the SSH client every other account runs. For an application
    holding a team's credentials in plaintext that is the trust model walked
    around rather than attacked. Every mode is now set absolutely.

Verified as far as a runner can: both packages install side by side on Fedora
44, resolve webkit2gtk4.1 2.52.5 — the backend MainWindow records as measured
— keep separate launchers, icons and profiles, and dnf remove takes one tree
and leaves the other. 0.0.1~alpha.0.143 sorts below both 0.0.1 and the next
height. What no runner can answer is Phase 19, which is new and unwalked.

RHEL 9 is deliberately not claimed. It ships WebKitGTK's 4.0 line under
libwebkit2gtk-4.0.so.37, and an or-branch for it was written and then removed:
nobody has run the head there, and dnf refusing with "nothing provides
libwebkit2gtk-4.1.so.0" is a better answer than an install that draws a blank
terminal. Same sentence, and the same reason, as the missing Intel Mac.

x86_64 only, for that reason and not a different one.
2026-08-22 16:43:38 +02:00

295 lines
18 KiB
RPMSpec

# ==================================================================================================
# The Linux desktop head, as an RPM.
#
# Built by scripts/ci-linux-rpm.sh, which is what supplies every dodo_* macro below and what checks
# the result. Nothing here is invoked by hand; `rpmbuild -bb` on this file with no --define set will
# fail on the Version line, which is the intended way round.
#
# ◆ WHY THIS IS AN RPM AND NOT A VELOPACK RELEASE, WHICH IS THE FIRST QUESTION.
#
# Both desktop platforms that ship today are Velopack: the client polls the project's forge, fetches
# a newer build and swaps itself over. ADR 0013 rule 4 is what makes that safe to hold — the feed
# address is a const, so no operator can answer "is there a newer version" on the user's behalf.
#
# This package has no updater in it at all, and that is deliberate rather than unfinished.
# VelopackUpdateChannel.ForThisMachine already refuses every platform that is not Windows or macOS,
# and it says why in a comment worth not undoing: a client handed an UpdateManager pointed at a feed
# carrying nothing it can apply is a client that reports itself current forever. On Linux the
# package manager is the updater — `dnf upgrade` is a mechanism the user already trusts, already
# audits and already controls — so the honest thing is to be an ordinary package and let it do that,
# rather than to bolt on a second one that answers to a different authority.
#
# The consequence to know: an installed RPM learns about a new version when its repository does, and
# this project publishes no repository yet. Somebody who downloaded the file installs the next one
# the same way.
# ==================================================================================================
# ◆ THE THREE THINGS THAT MUST BE TURNED OFF, AND WHAT EACH ONE WOULD DO.
#
# __os_install_post is the chain of brp-* scripts rpm runs over the buildroot: strip, strip-static,
# compress, elfperms. Every one of them is wrong here. The payload is a published .NET tree whose
# managed PDBs ADR 0013 rule 6 keeps on purpose — a stack with file names and line numbers is this
# project's entire diagnostic channel — and brp-strip would take the native symbols with them for a
# saving nobody asked for. It also runs the host's binutils over glibc binaries on a build host that
# is musl, which is a class of breakage with no good failure message.
#
# debug_package would then try to build a -debuginfo subpackage out of what strip extracted, from
# third-party binaries nobody here will ever step through.
#
# _build_id_links is the one that surprises people, because turning the other two off does not turn
# it off: rpm still writes /usr/lib/.build-id/<xx>/<hash> symlinks for every ELF file in the payload.
# For a bundled tree that means dozens of links in a system directory this package has no business
# owning, and two files with the same build id — which a .NET publish does produce — make it a build
# *warning* about duplicates rather than an error, so it goes unnoticed.
%global __os_install_post %{nil}
%global debug_package %{nil}
%global _build_id_links none
# ◆ AUTO-REQUIRES STAYS ON. AUTO-PROVIDES DOES NOT. THE ASYMMETRY IS THE WHOLE POINT.
#
# rpm's elfdeps reads DT_NEEDED and the symbol-version table out of every ELF file in the buildroot,
# and the requires half of that is worth much more than a hand-written list: it produces
# `libc.so.6(GLIBC_2.38)(64bit)` rather than `glibc`, so the package refuses to install on a host
# whose glibc is too old instead of installing and dying at the first launch. A hand list cannot
# express that and would go stale the first time a dependency changed underneath it.
#
# It reads ELF directly rather than by loading anything, so it does this correctly for x86_64 glibc
# binaries on a musl build host — which is what lets this package be built on the same Alpine runner
# as everything else in ci.yml.
#
# The provides half is the opposite. Everything under /opt here is a *bundled* copy: libSkiaSharp,
# libcoreclr, libe_sqlite3 and the rest belong to this application and to nothing else. Left on,
# this package would advertise `libcoreclr.so()(64bit)` to the whole system, and some other package's
# dependency could then be satisfied by a private copy inside a directory `dnf remove dodossh` takes
# away. Excluding the tree by path is the standard shape for that and costs nothing, because nothing
# outside this package is meant to link against anything inside it.
#
# ◆ WHICH IS WHY THE REQUIRES SIDE NEEDS AN EXCLUSION TOO, AND WHY IT IS COMPUTED.
#
# The two halves are not independent. A bundled library with a DT_NEEDED on another bundled library
# produces a requirement for a soname this package carries and — after the exclusion above — no
# longer advertises, which no host on earth can satisfy. It is not hypothetical and it is not rare:
# .NET's own libmscordbi.so links libmscordaccore.so, so the very first realistic payload this was
# tried against produced a package `dnf install` refuses outright.
#
# dodo_bundled is the alternation of every lib*.so* filename in the payload, worked out by
# scripts/ci-linux-rpm.sh from the tree it was handed rather than listed here. A list in this file
# would be a list of Microsoft's and SkiaSharp's internal file names, kept in step by hand, and its
# failure mode is the uninstallable package above.
#
# liblttng-ust is excluded on its own terms and not because it is bundled — it is not. It is what
# libcoreclrtraceptprovider.so links, and that file exists so that CoreCLR can emit LTTng trace
# events if a host has the tracing stack. Nothing here asks it to. The file is kept and the
# requirement is dropped, which is the arrangement that works both ways round: where liblttng-ust is
# present the dlopen succeeds and tracing is available, and where it is absent CoreCLR carries on, as
# it does on every machine that runs a .NET application without it. Requiring it instead would put
# the package out of reach on RHEL, where lttng-ust is in EPEL rather than in the base repositories —
# an install failure on a supported distribution, in exchange for a tracing integration this
# application does not use.
#
# scripts/ci-linux-rpm.sh checks the finished package against its own file list afterwards, because
# an exclusion that silently stopped matching would put the uninstallable package back with no
# warning anywhere.
%global __provides_exclude_from ^/opt/.*$
%global __requires_exclude ^(liblttng-ust\.so|%{dodo_bundled})
Name: %{dodo_name}
Version: %{dodo_version}
Release: %{dodo_release}
Summary: SSH client with an end-to-end encrypted credential vault
License: MIT
URL: https://git.dodotech.cloud/DodoTech-Public/DodoSSH
BuildArch: x86_64
# ◆ THE DEPENDENCIES elfdeps CANNOT SEE, WHICH ARE THE ONES THE PRODUCT IS MADE OF.
#
# Everything below is reached through dlopen, by a name composed at runtime, so it appears in no
# DT_NEEDED entry anywhere in the payload. Left to the automatic scan the package would install
# cleanly on a host with none of it and fail at the first window.
#
# GTK and WebKitGTK are the terminal. Avalonia.Controls.WebView P/Invokes a bare `libwebkit2gtk` and
# a bare `libgtk` and resolves them itself, and MainWindow.OnTerminalEnvironmentRequested documents
# the backend it was measured against: WebKitGTK 2.52.5 on Fedora 44, which is the 4.1 API line — the
# assembly's one fully-spelled soname, libjavascriptcoregtk-4.1.so.0, is that line's other half.
#
# ◆ THE 4.0 LINE IS DELIBERATELY NOT CLAIMED, AND THE COST OF THAT IS KNOWN.
#
# The control's own diagnostic says "webkit2gtk 4.0+", so a dependency permitting either line is
# tempting and was written first. RHEL 9 and its rebuilds are what it would buy: they ship
# webkit2gtk3, which is the 4.0 API under the soname libwebkit2gtk-4.0.so.37 — not .so.0, which is
# what makes this a thing to look up rather than guess. Requiring only 4.1 means `dnf install` on
# AlmaLinux 9 refuses with "nothing provides libwebkit2gtk-4.1.so.0", by design.
#
# It is refused because nobody has run it. This project does not ship an Intel macOS package for the
# same reason and says so in ADR 0013 decision 10 — the engineering is small and the verification is
# the scarce thing — and an install that succeeds and then draws a blank terminal is a worse answer
# than one that refuses in a sentence naming the missing library. The day somebody runs the head on
# a 4.0 host, this becomes a rich `or` and a line in the README.
#
# libX11 is Avalonia's own backend, P/Invoked the same way. On Wayland this is satisfied by XWayland,
# which every desktop shipping this package has.
#
# libicu is the one that looks removable and is not. DodoSSH.Client.App sets InvariantGlobalization
# to false — deliberately, and the csproj says why: this process prints timestamps and host names to
# a person who expects their own locale's conventions. A .NET process configured that way and given
# no ICU does not degrade to invariant, it refuses to start. The bare package name is the Fedora and
# RHEL spelling; it is the one name here that is a distribution's rather than a soname's, because ICU
# bumps its soname on every release and pinning one would make this package expire.
Requires: libwebkit2gtk-4.1.so.0()(64bit)
Requires: libgtk-3.so.0()(64bit)
Requires: libgdk-3.so.0()(64bit)
Requires: libgio-2.0.so.0()(64bit)
Requires: libglib-2.0.so.0()(64bit)
Requires: libgobject-2.0.so.0()(64bit)
Requires: libX11.so.6()(64bit)
Requires: libicu
# The size directories under /usr/share/icons/hicolor belong to hicolor-icon-theme and not to this
# package, which is the right way round — every application drops icons into them. Depending on the
# owner is how those directories come to exist at all on a minimal host, and how they get removed
# again when the last thing using them goes.
Requires: hicolor-icon-theme
%description
DodoSSH is an SSH and SFTP client for teams whose credentials are shared and whose infrastructure is
not. Hosts, keys and passphrases live in a vault that is encrypted on this machine and stays
encrypted on the server, so the deployment holding a team's secrets can never read them.
This package carries its own .NET runtime and is updated the way every other package on this system
is; it contains no self-updater and contacts no update service.
%install
rm -rf %{buildroot}
# ◆ /opt, AND NOT /usr/lib64/dodossh, WHICH IS THE MORE CONVENTIONAL PLACE.
#
# The Filesystem Hierarchy Standard reserves /opt for exactly this — an add-on package that arrives
# as one self-contained tree — and that is what a published .NET application is: a runtime, thirty
# managed assemblies and half a dozen native libraries that only make sense together and are only
# ever loaded by each other. Scattering them into the system library directory would mix a bundled
# libSkiaSharp in with the system's shared objects, which is the arrangement the provides exclusion
# above exists to keep this package out of.
#
# The directory name carries the channel, so a release and a nightly are two trees rather than one
# that overwrites the other. That is the same separation ADR 0013 rule 9 builds on Windows out of
# pack ids, arrived at here for free: two RPMs with different names owning different paths cannot
# replace one another, and dnf will say so rather than doing it. The profile directory is separated
# by the build itself — DodoChannel=nightly is what ClientPaths reads to suffix it — so the two
# installs share no state at all.
mkdir -p %{buildroot}/opt/%{dodo_dir}
cp -a %{dodo_payload}/. %{buildroot}/opt/%{dodo_dir}/
# ◆ AND THE MODES ARE SET HERE RATHER THAN INHERITED, WHICH IS NOT TIDINESS.
#
# `cp -a` preserves what the publish directory happened to have, and what it happened to have is a
# property of the filesystem it was written on rather than of anything decided here. rpmlint caught
# the consequence on the first tree this was tried against: every file in the package was mode 777,
# because the tree had come off a mount that reports everything that way.
#
# For this application that is not a lint warning, it is a hole. A world-writable /opt/DodoSSH lets
# any local account replace the SSH client belonging to every other account on the machine — the
# binary that holds a team's infrastructure credentials in plaintext while it runs, which is the
# thing ADR 0001 spends its whole trust model on. Malware running as nobody-in-particular would not
# need to attack the crypto; it would edit the client.
#
# Stated absolutely and in this order, so the answer does not depend on the publish at all:
# directories and the things that are executed or loaded get 0755, and everything else — assemblies,
# PDBs, the runtime config, the terminal's HTML and JavaScript — gets 0644. Owned by root, which is
# rpm's default for a package that names no %attr user, and is what makes the modes mean anything.
find %{buildroot}/opt/%{dodo_dir} -type d -exec chmod 0755 {} +
find %{buildroot}/opt/%{dodo_dir} -type f -exec chmod 0644 {} +
find %{buildroot}/opt/%{dodo_dir} -type f -name '*.so*' -exec chmod 0755 {} +
# The two files a .NET publish means to be executed. createdump is the runtime's crash-dump helper
# and is only present on some publishes, so it is conditional; the apphost never is, and the check
# above this section has already refused a publish without one.
chmod 0755 %{buildroot}/opt/%{dodo_dir}/DodoSSH
[ -f %{buildroot}/opt/%{dodo_dir}/createdump ] && chmod 0755 %{buildroot}/opt/%{dodo_dir}/createdump
true
# The apphost is the thing to launch and every other file in the tree is loaded relative to it, so
# what goes on PATH is a symlink rather than a copy or a wrapper script. .NET resolves its own
# directory through /proc/self/exe, which follows the link, so a symlinked apphost finds its runtime
# with no help — a wrapper exporting DOTNET_ROOT or cd-ing first would be solving a problem that
# does not exist here, and would be one more file to keep in step with the layout.
#
# Relative — ../../opt from /usr/bin — rather than the absolute /opt path it resolves to. Both work
# on a running system and only one works inside a chroot or an image being assembled somewhere other
# than /, which is where absolute symlinks out of a package are checked and warned about.
mkdir -p %{buildroot}%{_bindir}
ln -s ../../opt/%{dodo_dir}/DodoSSH %{buildroot}%{_bindir}/%{dodo_bin}
# ◆ THE DESKTOP ENTRY IS WRITTEN HERE RATHER THAN SHIPPED AS A SOURCE FILE.
#
# Three of its seven meaningful lines differ between the release and the nightly, and the two files
# that would result are the kind of pair that drifts: somebody adds a MIME type to one of them and
# nobody notices for a release. Composed from the same macros the rest of the layout uses, they
# cannot.
#
# StartupWMClass is the line that is easy to leave out and visibly wrong without: Avalonia sets
# WM_CLASS from the assembly name, which is DodoSSH for both channels, so without this a launched
# window is a second, unlabelled icon in the dock beside the one that launched it. It is the assembly
# name deliberately and not the package's binary name — the window does not know which package
# started it.
mkdir -p %{buildroot}%{_datadir}/applications
cat > %{buildroot}%{_datadir}/applications/%{dodo_bin}.desktop <<DESKTOP
[Desktop Entry]
Type=Application
Version=1.0
Name=%{dodo_title}
GenericName=SSH Client
Comment=SSH and SFTP with an end-to-end encrypted credential vault
Exec=%{dodo_bin}
Icon=%{dodo_bin}
Terminal=false
StartupNotify=true
StartupWMClass=DodoSSH
Categories=Network;RemoteAccess;System;
Keywords=SSH;SFTP;Terminal;Remote;Shell;
DESKTOP
# One file per size into the hicolor theme, named for the package so the two channels show different
# entries rather than fighting over one path. The sizes are the ones the icon script draws and its
# comment argues for; adding one there means adding it here.
for size in 48 64 128 256; do
mkdir -p %{buildroot}%{_datadir}/icons/hicolor/${size}x${size}/apps
install -m 0644 %{dodo_icons}/dodossh-${size}.png \
%{buildroot}%{_datadir}/icons/hicolor/${size}x${size}/apps/%{dodo_bin}.png
done
# Installed rather than listed straight out of the source tree. `%license` is a %doc-style marker and
# resolves its argument against the build directory, which this spec does not have — there is no
# %prep and no %build, because the payload arrives already published. Putting the file where it
# belongs first and marking the installed path second is the form that works with neither.
mkdir -p %{buildroot}%{_licensedir}/%{name}
install -m 0644 %{dodo_licence} %{buildroot}%{_licensedir}/%{name}/LICENSE
# Modern rpm on Fedora fires file triggers that do both of these, so on the distribution this was
# measured against they are redundant. They are here for the ones that do not, and every line ends in
# `|| :` so that a host without the tool installed gets a package that installs rather than a
# scriptlet failure at the end of a 130 MB transaction.
%post
/usr/bin/update-desktop-database %{_datadir}/applications &>/dev/null || :
/usr/bin/gtk-update-icon-cache -qtf %{_datadir}/icons/hicolor &>/dev/null || :
%postun
/usr/bin/update-desktop-database %{_datadir}/applications &>/dev/null || :
/usr/bin/gtk-update-icon-cache -qtf %{_datadir}/icons/hicolor &>/dev/null || :
%files
# The whole tree, owned by this package, which is what makes `dnf remove` take all of it. The user's
# vault cache is not in here and is not touched by an uninstall — ClientPaths puts it under
# $XDG_DATA_HOME, which is the Linux spelling of the same decision ADR 0013's consequences record for
# Windows: the install moves, the profile does not, and an uninstall is not a sign-out.
/opt/%{dodo_dir}
%{_bindir}/%{dodo_bin}
%{_datadir}/applications/%{dodo_bin}.desktop
%{_datadir}/icons/hicolor/*/apps/%{dodo_bin}.png
%license %{_licensedir}/%{name}/LICENSE
%changelog
# Deliberately empty. The changelog of record is the git history and the release notes on the forge,
# and a %changelog generated per build from a version number carries no information that `rpm -q`
# does not already answer — while a hand-written one is a third place a release has to remember to
# edit. rpmlint says W: no-changelog and that warning is accepted.