# ================================================================================================== # 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 replaces nothing and installs nothing, and that is deliberate rather than unfinished. # On Linux the package manager is already an updater the user trusts, audits and controls, so putting # a second one inside the application — answering to this project instead — would be building the # capability ADR 0013 rule 4 spends a page making unsettable, on the one platform where nobody had to. # # What the client does do is the half a package manager cannot: LinuxUpdateChannel notices that a # newer build exists on the project's forge, fetches it, checks its digest, and hands the file to # PackageKit — which raises polkit and asks. The user may say no, and nothing is replaced until they # say yes. See ADR 0013 rule 11. # # The consequence to know: discovery is worse here than on the other two desktops, because there is # no package repository to subscribe to and the in-app check is what stands in for one. # ================================================================================================== # ◆ 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// 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 # ◆ RECOMMENDED RATHER THAN REQUIRED, WHICH IS A JUDGEMENT ABOUT WHAT THE PACKAGE IS FOR. # # PackageKit is what LinuxUpdateChannel hands a downloaded update to, so without it the update path # stops at the last step. It is not required, for two reasons that both point the same way: dnf # installs weak dependencies by default, so the ordinary Fedora install gets it anyway; and a machine # deliberately built without it — a minimal or an immutable one — should still be able to install an # SSH client. The failure is not silent, which is what makes this safe: the channel names the missing # service and the file it fetched, so the answer is one command the user was going to run anyway. # # Nothing is required for the TPM. LinuxDeviceKeyStore talks to /dev/tpmrm0 through a managed stack, # so there is no library to depend on, and a machine with no TPM — or one whose device node this user # may not open, which is the ordinary Fedora case — simply keeps asking for the passphrase. Recommends: PackageKit %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. It checks the project's own forge for newer builds and can download one, but it installs nothing itself: an update is handed to PackageKit, which asks before it replaces anything. The address it checks is fixed and is never a DodoSSH server. %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 </dev/null || : /usr/bin/gtk-update-icon-cache -qtf %{_datadir}/icons/hicolor &>/dev/null || : # Reloaded and replayed, because a rule that only takes effect at the next boot is a feature that # does not work on the day it is installed — and "reboot and try again" is the worst possible answer # to "why is the PIN option missing". The trigger is scoped to the tpmrm subsystem so this re-runs # rules for one device rather than for every device on the machine. /usr/bin/udevadm control --reload &>/dev/null || : /usr/bin/udevadm trigger --subsystem-match=tpmrm --action=change &>/dev/null || : %postun /usr/bin/update-desktop-database %{_datadir}/applications &>/dev/null || : /usr/bin/gtk-update-icon-cache -qtf %{_datadir}/icons/hicolor &>/dev/null || : # On the way out too, so removing the package takes the ACL with it rather than leaving the last # session's user holding access to the TPM until the next reboot. /usr/bin/udevadm control --reload &>/dev/null || : /usr/bin/udevadm trigger --subsystem-match=tpmrm --action=change &>/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 /usr/lib/udev/rules.d/71-dodossh-tpm.rules %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.