diff --git a/.gitattributes b/.gitattributes index 8ce2d48..ac4ad03 100644 --- a/.gitattributes +++ b/.gitattributes @@ -20,8 +20,11 @@ *.sql text # Files that must keep LF regardless of platform — shell scripts run in Linux -# containers, and Dockerfiles are read by a Linux daemon. +# containers, Dockerfiles are read by a Linux daemon, and an RPM spec is a shell +# script in disguise: rpmbuild feeds its %install section to /bin/sh, where a +# carriage return at the end of a line is an argument rather than nothing. *.sh text eol=lf +*.spec text eol=lf Dockerfile text eol=lf *.Dockerfile text eol=lf Caddyfile text eol=lf diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c45665b..95c22e1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -462,6 +462,55 @@ jobs: echo "Bundled DodoSSH ${{ steps.winpack.outputs.packVersion }} for osx-arm64." + # ◆ AND THE THIRD DESKTOP PLATFORM, WHICH IS THE ONE THIS RUNNER ACTUALLY IS. + # + # Same argument as the two steps above and with none of their cross-compiling: this job runs on + # Linux, so the linux-x64 publish here is a native one. What it proves that the others cannot is + # the linux-x64 restore graph — libsodium, SkiaSharp and HarfBuzzSharp all ship native assets per + # RID, and a package that resolves for win-x64 and has no linux-x64 asset fails here. + # + # The runner is still Alpine and the package targets glibc distributions, which is fine and is not + # glossed over: scripts/ci-linux-rpm.sh explains why rpmbuild composing an RPM for one libc on a + # host running another is correct rather than lucky. + # + # ◆ IT IS ALSO WHERE THE PACKAGING ITSELF IS PROVED, WHICH ON THIS PLATFORM IS NOT A FORMALITY. + # + # The Windows and macOS steps hand a directory to vpk and vpk decides what the package needs. + # There is no vpk here — Velopack's Linux target is an AppImage and this is a distribution + # package, updated by dnf rather than by the application — so the dependency metadata is + # generated by rpm from the published tree, and scripts/ci-linux-rpm.sh interrogates the result + # rather than trusting an exit code. Every check in there is a way a package can be built + # successfully and then refuse to install on every host in the world; one of them was written + # because .NET's own libmscordbi.so links libmscordaccore.so, which is exactly that. + # + # Release-shaped rather than nightly-shaped — package name dodossh, tree at /opt/DodoSSH — so + # that what is proved on every main build is the packaging the release channel would use. The + # desktop job below builds the nightly-shaped one and is the only job that publishes either. + # + # ◆ NOTHING IS UPLOADED, FOR THE REASON THE WINDOWS STEP GIVES. + # + # RUNNER_TEMP, dying with the job. An RPM is if anything the easiest of the three to pass on + # casually — one file, no installer to click through, `dnf install` and done — which is a reason + # to keep it off a downloads page rather than a reason to put it on one. ADR 0013 rule 3. + - name: publish and package the linux desktop client + if: github.event_name != 'pull_request' + run: | + set -euo pipefail + + # RestoreLockedMode=false for the RID, exactly as the two publishes above do — see the long + # note on the win-x64 one. This checkout is thrown away, so the lock files it rewrites go + # nowhere. + dotnet publish src/DodoSSH.Client.App/DodoSSH.Client.App.csproj \ + --configuration Release --runtime linux-x64 --self-contained true \ + -p:RestoreLockedMode=false \ + --output "$RUNNER_TEMP/linux-x64" + + scripts/ci-linux-rpm.sh \ + --payload "$RUNNER_TEMP/linux-x64" \ + --version '${{ steps.winpack.outputs.packVersion }}' \ + --channel release \ + --out "$RUNNER_TEMP/linux-rpm" + # This includes the end-to-end suite, which starts PostgreSQL, Keycloak and an OpenSSH # server through Testcontainers and runs the API as a child process — so it needs a # Docker daemon and gets one here. That is why the tests run on ubuntu rather than @@ -1048,6 +1097,45 @@ jobs: ls -la "$RUNNER_TEMP/nightly-releases" + # ◆ AND THE LINUX NIGHTLY, WHICH IS A PACKAGE RATHER THAN A FEED. + # + # Everything above this step is Velopack: an installer, an index file and a client that polls it. + # This one is an RPM, and the difference is not a gap in the work. VelopackUpdateChannel refuses + # every platform that is not Windows or macOS — deliberately, and its comment says why — so a + # Linux build has no self-updater to give a channel to. What it has instead is dnf, which is a + # mechanism the user already trusts and already controls, and build/linux/dodossh.spec argues + # that at length. + # + # So there is no rolling *feed* here, only a rolling *file*: the newest nightly RPM sits on the + # same release as the Windows nightly and somebody who wants it downloads it. That is a worse + # deal than the Windows nightly gets and it is the honest one — the alternative is a package + # repository, which is a deployment this project does not have. + # + # DodoChannel=nightly for the same reason the win-x64 publish above sets it: it is what + # ClientPaths reads to suffix the profile directory, so the nightly RPM and a release RPM + # installed beside it share no cache, no outbox and no device key. The package name, the tree + # under /opt and the desktop entry are the packaging half of that separation and are the + # script's `--channel nightly` — see the note there for why it is four things and not one. + - name: publish and package the linux nightly + id: linuxpack + env: + VERSION: ${{ steps.version.outputs.version }} + run: | + set -euo pipefail + + dotnet publish src/DodoSSH.Client.App/DodoSSH.Client.App.csproj \ + --configuration Release --runtime linux-x64 --self-contained true \ + -p:RestoreLockedMode=false \ + -p:DodoChannel=nightly \ + -p:MinVerVersionOverride="$VERSION" \ + --output "$RUNNER_TEMP/nightly-linux-x64" + + scripts/ci-linux-rpm.sh \ + --payload "$RUNNER_TEMP/nightly-linux-x64" \ + --version "$VERSION" \ + --channel nightly \ + --out "$RUNNER_TEMP/nightly-rpm" + # ◆ PUBLISHING IT, AND WHAT THAT CAPABILITY IS. # # Whoever can write a release here can put a build on every nightly desktop, because Velopack fetches @@ -1145,7 +1233,45 @@ jobs: fi done - echo "Published nightly desktop $VERSION as $TAG" + # ◆ THE RPM GOES ONTO THE RELEASE vpk JUST MADE, RATHER THAN ONTO ONE OF ITS OWN. + # + # One rolling release carrying both heads, because they are one nightly: the same commit, + # the same version, and one page a person looks at to find out what is on main. A second tag + # would double the delete-and-recreate dance this step already does, and would let the two + # drift apart on a run where one of them failed. + # + # It has to happen here, after `vpk upload`, and not in a step before it. vpk deletes and + # recreates this release, so an RPM uploaded first would be attached to something that no + # longer exists by the time the job ends — silently, because the delete succeeds. + # + # curl rather than vpk, unlike the assets above, for the reason the android job gives about + # its APK: this is one file the release simply carries. There is no index to write and no + # format that belongs to a tool. + release="$(printf '%s' "$published" | release_id)" + if [ -z "$release" ]; then + echo "The release was published and its id could not be read back:" >&2 + printf '%s\n' "$published" >&2 + exit 1 + fi + + rpm='${{ steps.linuxpack.outputs.rpm }}' + rpmName='${{ steps.linuxpack.outputs.rpmName }}' + echo "Uploading $rpmName ($(wc -c < "$rpm") bytes)" + curl -fsS -X POST -H "$auth" \ + -F "attachment=@$rpm" \ + "$api/releases/$release/assets?name=$rpmName" >/dev/null + + # Asked for back, exactly as the Velopack assets above are and for the same history: the + # android job once created a release, uploaded nothing, and reported success for every + # upload it never made. + withRpm="$(curl -fsS -H "$auth" "$api/releases/$release")" + if ! printf '%s' "$withRpm" | grep -qF "\"name\":\"$rpmName\""; then + echo "The release was created but $rpmName is not on it:" >&2 + printf '%s\n' "$withRpm" >&2 + exit 1 + fi + + echo "Published nightly desktop $VERSION as $TAG, with $rpmName" image: name: api image diff --git a/README.md b/README.md index 21e8139..a742c30 100644 --- a/README.md +++ b/README.md @@ -177,6 +177,43 @@ passphrase screen, always. The application lives at `/Applications/DodoSSH.Deskt at `~/Library/Application Support/DodoSSH`, which are deliberately two different places so that removing the first never touches the second. +## Installing on Linux + +An `.rpm` for x86_64, and the one platform where you get it from `main` rather than from a release — see +[the nightly build](#the-nightly-desktop-build) below, which is where the Linux package is published today. + +```bash +sudo dnf install ./dodossh-nightly-*.x86_64.rpm +``` + +Everything above about where a client may come from applies unchanged. Four things differ, and the first +two are the ones to read before installing. + +**There is no self-updater, and that is the design rather than a gap.** The Windows and macOS builds carry +Velopack and replace themselves; this one carries nothing of the sort and never contacts an update service. +On Linux the package manager is already an updater the person running it trusts, audits and controls, and +adding a second one that answers somewhere else would be handing back exactly the capability +[ADR 0013](docs/adr/0013-desktop-distribution-and-updates.md) rule 4 spends a page keeping out of reach. +The cost is real and worth stating plainly: this project publishes no package repository, so nothing tells +you when a newer build exists. You install the next one the way you installed this one. + +**Your passphrase is asked for every time, because there is no hardware key store here.** Windows has a TPM +behind a consent dialog and a Mac has the Secure Enclave behind Touch ID; the Linux head has neither wired +up, so the "unlock without the passphrase" path is simply not offered — the application knows it has no +store and does not pretend otherwise. See +[docs/unlocking-without-the-passphrase.md](docs/unlocking-without-the-passphrase.md). + +**It needs WebKitGTK, because the terminal is a WebView.** Specifically `webkit2gtk4.1`, which `dnf` pulls +in for you. Fedora 44 is the distribution the head has actually been run on. RHEL 9 and its rebuilds ship +the older 4.0 API line under a different library name and the package deliberately does not claim them — +`dnf` will refuse with *nothing provides libwebkit2gtk-4.1.so.0*, which is a better answer than installing +and drawing a blank terminal. This project does not ship desktop builds nobody has run, on any platform. + +**Where things go.** The application at `/opt/DodoSSH`, launched as `dodossh` or from your menu; your vault +cache at `$XDG_DATA_HOME/dodossh`, usually `~/.local/share/dodossh`. Two different places on purpose: +`dnf remove` takes the first and never touches the second, so reinstalling asks for your passphrase rather +than starting over. Use **Sign out** inside the application to make the machine genuinely forget. + Cutting a release is `scripts/release-windows.ps1`, run by a person on a Windows machine, and `scripts/release-macos.sh` on a Mac. Deliberately not a CI job; ADR 0013 decision 3 explains why, and it is not only that the runners are Linux. @@ -189,6 +226,18 @@ Nightly*, its own install directory, and its own profile at `%LOCALAPPDATA%\Dodo costs nothing you are relying on. It signs in separately and appears to your account as a new device, because it genuinely is a second installation. +**The Linux `.rpm` is on the same release**, built from the same commit and versioned the same way, as +`dodossh-nightly`. It installs beside a release package for the same reasons and by the same four +separations — a different package name, a tree at `/opt/DodoSSH.Nightly`, a `dodossh-nightly` launcher with +its own menu entry, and a profile at `~/.local/share/dodossh.nightly`. Two RPMs owning different paths +cannot replace one another, so `dnf` will keep both rather than treating one as an upgrade of the other. + +It is the only Linux package this project publishes at all today: there is no `v*`-tagged Linux release +yet, because no person has walked a Linux build through +[docs/manual-checks.md](docs/manual-checks.md). What CI proves on every push to `main` is that the release +package still *builds* — the same arrangement Windows and macOS have had since the beginning, where the +package is built on every commit and uploaded nowhere. + **Installing it means trusting everyone who can write a release on this repository, including CI.** The updater applies what the feed serves without verifying a signature, so anyone who can change a workflow file here can put a build on every nightly machine. That is an acceptable trade for a build you are trying @@ -920,10 +969,21 @@ keychain plus a terminal — and the spike that gates all of it. [ADR 0013](docs/adr/0013-desktop-distribution-and-updates.md) decision 10 and [Installing on macOS](#installing-on-macos). + **The Linux half is a package rather than an updater.** An `.rpm` for x86_64, built and checked on every + push to `main` and published to the nightly release, installing to `/opt` with a menu entry and a `dodossh` + launcher. It carries no self-updater at all and contacts no update service — on this platform `dnf` is + already an updater the user controls, and a second one answering somewhere else is the capability the + trust model exists to keep out of reach. It also has no hardware-backed device key store, so the + passphrase is asked for every unlock. See + [ADR 0013](docs/adr/0013-desktop-distribution-and-updates.md) decision 11 and + [Installing on Linux](#installing-on-linux). + Still to do here: Windows signing (the first Windows release is unsigned, and the trigger for buying a - certificate is the first release aimed at strangers), macOS on Intel, Linux packaging, and Phase 18 of the - manual checks — the macOS build has never actually run, because there is no macOS runner in CI and - everything above is verified only as far as the bundle. + certificate is the first release aimed at strangers), macOS on Intel, a `v*`-tagged Linux release and a + package repository to serve it from, a Linux device key store, and Phases 18 and 19 of the manual + checks — neither the macOS nor the Linux build has been walked through by a person. There is no macOS + runner in CI at all, and the Linux package is verified only as far as what it *says* about itself: CI + builds it and interrogates its dependencies, its file list and its modes, and installs nothing. - **M5 — multi-provider OIDC**, identity key rotation, per-item content keys. ## Licence diff --git a/build/linux/dodossh.spec b/build/linux/dodossh.spec new file mode 100644 index 0000000..02e9ae3 --- /dev/null +++ b/build/linux/dodossh.spec @@ -0,0 +1,294 @@ +# ================================================================================================== +# 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// 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 </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. diff --git a/docs/adr/0013-desktop-distribution-and-updates.md b/docs/adr/0013-desktop-distribution-and-updates.md index 52eb127..13a9a6c 100644 --- a/docs/adr/0013-desktop-distribution-and-updates.md +++ b/docs/adr/0013-desktop-distribution-and-updates.md @@ -316,6 +316,66 @@ preferences screen, which is the screen somebody reads when asked which nightly across the floor holds: heights rise within a floored version, and the first real tag moves past all of them. The release script gets no floor and must not have one; there, `0.0.0` should be refused. +### 11. Linux is a third desktop platform, and it is a package rather than an updater + +The Linux head existed and shipped as nothing: a checkout somebody built. `VelopackUpdateChannel` already +named the two platforms it serves rather than negating the one it does not, on the reasoning that "a future +AppImage should be an addition here rather than a thing that silently already half-happened". This is that +addition, and it is not an AppImage. + +**The package is an RPM and there is no updater in it.** That is the decision, and everything else follows +from it. Velopack does have a Linux path, so the shape of decisions 1 through 9 could have been repeated a +third time — one more channel, one more index, one more client polling the forge. It should not be, because +this platform already has the mechanism that whole apparatus is a substitute for. `dnf` is an updater the +user already trusts, already audits and already controls, and it updates the runtime's own dependencies +besides. Adding a second one that answers to this project instead would be building the capability decision +4 spends a page keeping unsettable, on the one platform where nobody had to. + +The honest cost, recorded because it is the part that will be argued with: **discovery is worse here than on +either other desktop.** Windows and macOS clients learn about a new build within six hours without anybody +deciding to look. A Linux user learns when their repository does, and this project publishes no repository, +so today they learn when they next go and look. That is a real regression against the sharpest thing this +ADR bought, accepted because the alternative regression — a second update path with a different authority +behind it — is the one the trust model cannot afford. A package repository is the fix and is not built. + +**CI publishes it, and only to the nightly channel.** Rule 3 is untouched and applies with its full force: +the token that writes a release is the capability to ship somebody a build, and it stays off runners. What +changes is nothing, because rule 9 already established a channel CI may publish to and this is a second +artefact on it. The release-shaped package is built on every `main` build and on every tag and uploaded +nowhere, exactly as the Windows and macOS packages are, which proves the `linux-x64` restore graph and the +packaging without putting an installer anybody has run in a place that invites passing it on. + +Note what CI's check is and is not. `vpk` verifies that the main executable actually calls +`VelopackApp.Build().Run()` — a real assertion about the application, made for free by the packager. There +is no equivalent here, so `scripts/ci-linux-rpm.sh` makes its own: it asks the finished package what it +requires, what it provides, what files it holds and what modes they carry, and fails on each way a package +can build cleanly and then be uninstallable or unsafe. Two of those checks exist because the first realistic +payload tripped them — .NET's `libmscordbi.so` links `libmscordaccore.so`, which with bundled provides +excluded produces a dependency no host can satisfy; and `cp -a` off the wrong filesystem produced a +world-writable `/opt/DodoSSH`, which is a local privilege escalation against an application holding +plaintext credentials. + +**The separations of rule 9 survive translation, and cost less here.** Two RPMs with different names owning +different paths cannot replace one another and `dnf` says so, where on Windows the same property had to be +built out of pack ids. The profile split is unchanged and still belongs to the build: `DodoChannel=nightly` +is what `ClientPaths` reads, and the packaging must not answer that question a second time. + +**Two things this platform does not have, and they are stated rather than left to be discovered.** There is +no hardware-backed device key store — `DesktopDeviceKeyStores` returns `UnavailableDeviceKeyStore`, so the +passphrase is asked for at every unlock, where Windows has a TPM and a Mac has the Secure Enclave. And there +is no signature on the package, which is not decision 8's "unsigned for now": nothing on this platform +refuses or warns about an unsigned RPM, so there is no dialog to buy a certificate to remove. What stands in +for one is the same thing that stands in for it on the forge — TLS, and a project that publishes from one +address. An RPM repository would change that, because repository metadata is signed and a repository is a +thing users add once and then trust; that is the point at which a key becomes necessary rather than optional, +and it is the reason building one is a decision and not a chore. + +**x86_64 only, for decision 10's reason and not a different one.** aarch64 is one argument to the publish +and one to the script. What is missing is somebody to run Phase 19 on such a machine, and this project does +not ship desktop builds nobody has run. The same sentence rules out claiming RHEL 9, which ships WebKitGTK's +4.0 API line under a soname the package deliberately does not accept: `dnf` refusing with *nothing provides +libwebkit2gtk-4.1.so.0* is a better answer than an install that draws a blank terminal. + ## Consequences **The desktop gets what ADR 0011 had to refuse the phone.** Discovery is still manual — somebody has to be @@ -369,6 +429,10 @@ ADR 0011 changes. genuinely the nicest onboarding available and will be proposed again. - **Conditioning updates on `MinClientVersion`.** See decision 5. It would create the attack rather than inherit it. +- **A Velopack channel for Linux.** The mechanism exists — Velopack builds an AppImage — and repeating + decisions 1 through 9 a third time would have been the consistent-looking move. Declined in decision 11 + because this is the one desktop platform that already has an updater the user controls, and a second one + answering to this project is the capability decision 4 exists to make unsettable. - **Azure Trusted Signing**, for now, on custody grounds rather than on mechanics. See decision 8. - **A hand-edited version property.** Considered seriously against MinVer, and it loses on the thing that matters here: the tag is already the version of record for the container image, and two places to write diff --git a/docs/manual-checks.md b/docs/manual-checks.md index d62f1e4..23720ba 100644 --- a/docs/manual-checks.md +++ b/docs/manual-checks.md @@ -2841,3 +2841,112 @@ Register a device, sync something, then remove the application. **Failure means:** an empty directory is the pack id having been changed to `DodoSSH`, which puts Velopack's install root on top of `ClientPaths.DataDirectory` and makes an uninstall delete a user's un-synced work. This is the single reason the bundle is named `DodoSSH.Desktop.app`. + +## Phase 19 — Installing the Linux client, and the things dnf does not check + +The Linux counterpart of phases 16 and 18, and the shortest of the three because the platform takes two of +their questions off the table. There is no updater here, so nothing in this phase asks whether an update +arrives; and there is no signing, because nothing on this platform refuses an unsigned package — a distinct +statement from "unsigned is fine", and `build/linux/dodossh.spec` says what stands in for a signature: the +package comes from the project's own forge over TLS and from nowhere else. + +What that leaves is the set nobody else can answer. **CI builds this package and installs nothing**, and +every check in `scripts/ci-linux-rpm.sh` is a check on what the package *says* — its dependencies, its file +list, its modes. None of them start the application, and the failures below are all of the shape "installs +perfectly and then does not work". + +Needs a Fedora 44 machine or newer with a graphical session. Build the package first: + +```bash +dotnet publish src/DodoSSH.Client.App/DodoSSH.Client.App.csproj \ + --configuration Release --runtime linux-x64 --self-contained true \ + -p:RestoreLockedMode=false --output /tmp/linux-x64 +scripts/ci-linux-rpm.sh --payload /tmp/linux-x64 --version 1.2.3 --out /tmp/rpm +sudo dnf install /tmp/rpm/dodossh-1.2.3-1.x86_64.rpm +``` + +### 19.1 It launches from the menu, and not only from a shell · **do this one first** + +Log out and back in, or wait for the desktop to notice the new `.desktop` file, then start DodoSSH from the +applications menu rather than from a terminal. + +**Pass:** the window opens, and the running application's icon in the dock or task list is the same entry +that launched it — one icon, not two. + +**Failure means:** a second unlabelled icon appearing beside the launcher is `StartupWMClass` not matching +what Avalonia sets `WM_CLASS` to. The spec sets it to `DodoSSH`, which is the assembly name and is the same +for both channels; if Avalonia's answer has changed, `xprop WM_CLASS` on the window is what to compare +against. Nothing at all in the menu is the desktop database not having been rebuilt — `%post` runs +`update-desktop-database` and swallows its failure on purpose, so try it by hand before concluding anything. + +### 19.2 The terminal draws, which is the WebKitGTK question + +Connect to a host and use the shell: type, run something that scrolls, resize the window. + +**Pass:** the terminal attaches within a second or two and behaves as it does on Windows. + +**Failure means:** **a blank pane where everything else works is the known one**, and it is the reason +`MainWindow.OnTerminalEnvironmentRequested` exists — the GTK WebView adapter in its default mode can only +open a window of its own, so a control asked to host it in place has nothing to draw. If that method has +stopped setting `ExperimentalOffscreen`, or the vendor has withdrawn the flag, this is what it looks like. +A pane that reports a renderer timeout instead is the loopback WebSocket rather than the backend. + +### 19.3 The icon is the mark, at the sizes the desktop actually asks for + +Look at it in the applications menu, in the dock, and in the window switcher. + +**Pass:** the accent tile and the `>_` mark, filling its square, legible at the small end. + +**Failure means:** a generic icon is `Icon=` in the desktop entry not matching an installed file name — both +are the package's binary name, so they can only disagree if one was edited. A blurry small icon is the +48-pixel file missing and the 256 being scaled down; `dodossh-icon.ps1` draws four sizes and the spec +installs four. + +### 19.4 The passphrase is asked for, and no device key is offered + +Open the security settings page. + +**Pass:** registering a device key is **not** offered, and unlocking asks for the passphrase every time. + +**Failure means:** being offered it is `DesktopDeviceKeyStores.ForThisMachine` having returned something +other than `UnavailableDeviceKeyStore` on a platform with nothing to back it. Accepting the offer would put +a wrap on the server that nothing can ever open. + +### 19.5 The nightly installs beside the release rather than over it + +With the release package installed, install the nightly one too. + +**Pass:** `dnf` installs it as a second package rather than as an upgrade. Both entries appear in the menu, +named *DodoSSH* and *DodoSSH Nightly*. Both run at once. Signing into the nightly asks for the passphrase +again and appears on the account as a separate device. + +**Failure means:** `dnf` offering to *upgrade* rather than install is the two packages sharing a name. A +nightly that opens the release's vault already unlocked is the profile separation gone — that comes from +`DodoChannel=nightly` reaching `ClientPaths`, not from the packaging, so check +`~/.local/share/dodossh.nightly` exists rather than looking at the spec. + +### 19.6 Nothing in the installed tree is writable by anyone but root + +```bash +find /opt/DodoSSH ! -user root -o -perm -o+w +``` + +**Pass:** no output. + +**Failure means:** any line here is a local account being able to replace the SSH client every other account +on the machine runs, which for an application holding a team's credentials in plaintext is the whole trust +model walked around rather than attacked. `ci-linux-rpm.sh` refuses to produce such a package and the spec +sets every mode absolutely, so a hit here means one of those two was changed — it cannot arrive by accident +from a publish directory. + +### 19.7 Removing it leaves the vault, and leaves the other channel alone + +Register a device, sync something, then `sudo dnf remove dodossh`. + +**Pass:** `~/.local/share/dodossh` still holds the cache and the outbox. `/opt/DodoSSH` is gone entirely. +`/opt/DodoSSH.Nightly` and its menu entry are untouched. + +**Failure means:** a missing cache is `ClientPaths` having been pointed somewhere under `/opt`, which would +make an uninstall delete a user's un-synced work — the Linux spelling of the mistake ADR 0013 decision 2 +exists to prevent. Files left behind under `/opt/DodoSSH` are files the package did not own, which means +something wrote into the install directory at runtime and would survive a reinstall. diff --git a/scripts/ci-linux-rpm.sh b/scripts/ci-linux-rpm.sh new file mode 100755 index 0000000..aa5c273 --- /dev/null +++ b/scripts/ci-linux-rpm.sh @@ -0,0 +1,320 @@ +#!/usr/bin/env bash +# +# Turns a published linux-x64 tree into an RPM, and then checks the RPM rather than the exit code. +# +# Called from two jobs in .github/workflows/ci.yml and written once for the reason the four copies of +# "ensure node and git" in that file are not: those cannot be factored out — a composite action would +# need the checkout it exists to unblock — and this can. The build job packages the release-shaped +# RPM and throws it away, exactly as it does for Windows and macOS; the desktop job packages the +# nightly-shaped one and publishes it. Everything that differs between those two is an argument. +# +# It is also runnable at a desk, which is the point of it being a script: +# +# dotnet publish src/DodoSSH.Client.App/DodoSSH.Client.App.csproj \ +# --configuration Release --runtime linux-x64 --self-contained true \ +# -p:RestoreLockedMode=false --output /tmp/linux-x64 +# scripts/ci-linux-rpm.sh --payload /tmp/linux-x64 --version 1.2.3 --out /tmp/rpm +# +# Nothing here holds a secret and nothing here uploads anything. Publishing is the calling job's, and +# ADR 0013 rule 3 is what decides which channel may do it. + +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +payload="" +version="" +channel="release" +out="" + +while [ $# -gt 0 ]; do + case "$1" in + --payload) payload="$2"; shift 2 ;; + --version) version="$2"; shift 2 ;; + --channel) channel="$2"; shift 2 ;; + --out) out="$2"; shift 2 ;; + *) echo "Unknown argument: $1" >&2; exit 2 ;; + esac +done + +for required in payload version out; do + if [ -z "${!required}" ]; then + echo "--$required is required." >&2 + exit 2 + fi +done + +case "$channel" in + release|nightly) ;; + *) echo "--channel must be release or nightly, not '$channel'." >&2; exit 2 ;; +esac + +# The apphost, named rather than globbed. A publish that produced no apphost at all would otherwise +# package happily into an RPM whose /usr/bin symlink points at nothing, and the first thing anybody +# would learn about it is a shell saying "No such file or directory" for a file that is right there. +if [ ! -s "$payload/DodoSSH" ]; then + echo "There is no apphost at $payload/DodoSSH, so there is nothing to package." >&2 + ls -la "$payload" >&2 || true + exit 1 +fi + +# ◆ THE FOUR THINGS THAT DIFFER BETWEEN THE CHANNELS, AND WHY IT IS FOUR AND NOT ONE. +# +# This is ADR 0013 rule 9's table, in the spelling this platform uses. The package name keeps dnf from +# treating one as an upgrade of the other; the directory under /opt keeps the two trees apart on disk; +# the /usr/bin name and the desktop entry keep them apart in front of a person, who would otherwise +# have two identical launchers and no way to tell which was which. +# +# The fifth separation — the profile directory holding the vault cache, the outbox and the device key +# — is not here and must not be, because it belongs to the build rather than to the package: the +# nightly is published with DodoChannel=nightly, and ClientPaths reads that to suffix the directory. +# Doing it here as well would be a second answer to a question that already has one. +if [ "$channel" = nightly ]; then + name=dodossh-nightly + dir=DodoSSH.Nightly + bin=dodossh-nightly + title="DodoSSH Nightly" +else + name=dodossh + dir=DodoSSH + bin=dodossh + title="DodoSSH" +fi + +# ◆ SEMVER IS NOT AN RPM VERSION, AND THE TRANSLATION IS THE ONE PLACE THIS CAN GO QUIETLY WRONG. +# +# rpm forbids `-` in a version, which is exactly the character SemVer uses to introduce a prerelease. +# MinVer's answer on main is 0.0.1-alpha.0.143, so the naive move — strip the suffix — would give +# every nightly the version 0.0.1 and make each one look like a reinstall of the last. +# +# `~` is rpm's own answer and has been since 4.10: it sorts *before* the empty string, so +# 0.0.1~alpha.0.143 is older than 0.0.1 the way alpha.143 is older than the release. Heights keep +# rising within a floored version and the first real tag moves past all of them, which is the same +# ordering property the Velopack channels have and is worth having on both. +# +# Build metadata after `+` is dropped rather than translated. It is the one part of a SemVer string +# that is defined as not participating in ordering, so keeping it would put a character rpm has no +# rule for into the field ordering is done on. +rpm_version="${version%%+*}" +rpm_version="${rpm_version/-/\~}" + +case "$rpm_version" in + *-*) + echo "The version $version translates to $rpm_version, which still contains a hyphen." >&2 + echo "rpm cannot order a version with one in it; this needs a rule rather than a retry." >&2 + exit 1 + ;; +esac + +# Always 1. The release field distinguishes several packagings of one upstream version, and there is +# only ever one packaging here — the version already carries the build height, because upstream and +# packager are the same repository. +rpm_release=1 + +echo "==> $name $rpm_version-$rpm_release.x86_64, from $payload" + +# ◆ rpmbuild ON A MUSL HOST, WHICH IS FINE, AND WHY IT IS WORTH SAYING SO. +# +# The runner is Alpine. Nothing about that shows up in the package: rpmbuild composes an archive and +# some metadata, and the one part of it that reads the payload — elfdeps, which the spec leaves on for +# requires — parses ELF headers with libelf rather than loading anything, so it reads x86_64 glibc +# binaries on a musl host correctly. Building in a Fedora container was the alternative and would +# have meant `docker cp`-ing a 130 MB publish tree in and the package back out, because this runner is +# itself a container and a bind mount resolves on the daemon's host rather than here — see the note on +# the android job for the hour that cost. +if ! command -v rpmbuild >/dev/null 2>&1; then + SUDO="" + [ "$(id -u)" -eq 0 ] || SUDO="sudo" + + echo "==> installing rpm" + if command -v apk >/dev/null 2>&1; then + $SUDO apk add --no-cache rpm + elif command -v dnf >/dev/null 2>&1; then + $SUDO dnf install -y rpm-build + elif command -v apt-get >/dev/null 2>&1; then + $SUDO apt-get update -qq + $SUDO apt-get install -y --no-install-recommends rpm + else + echo "No apk, dnf or apt-get here, so rpmbuild cannot be installed from inside the job." >&2 + echo "Add rpm to the runner's image; the Linux package cannot be built without it." >&2 + exit 1 + fi +fi +rpmbuild --version + +# ◆ THE LIST OF PRIVATE SONAMES, WORKED OUT FROM THE TREE RATHER THAN WRITTEN DOWN. +# +# build/linux/dodossh.spec turns rpm's automatic provides off for everything under /opt, because the +# libraries there are this application's private copies and have no business satisfying anybody +# else's dependency. The requires side has to be told the same thing about the same set, or a bundled +# library that links another bundled library asks for a soname nothing provides and the package +# becomes uninstallable on every host — which .NET's own libmscordbi.so, linking libmscordaccore.so, +# does on every publish. +# +# Derived here because the set belongs to the payload. A list in the spec would be Microsoft's and +# SkiaSharp's internal file names, maintained by hand, going stale on the first runtime bump — and +# going stale in the direction that produces a package nobody can install. +# +# Only the top level is scanned, which is where a .NET publish puts its native libraries. The dots +# are escaped because this becomes a regular expression; nothing else in a soname is a metacharacter. +# +# A shell glob rather than `find -printf`, which is GNU find's and not busybox's — the runner is +# Alpine, where that flag is a usage message and an empty list, which would silently disable the whole +# exclusion and put the uninstallable package back. +bundled="$( + for library in "$payload"/lib*.so*; do + [ -f "$library" ] && basename "$library" + done | + sed 's/\./\\./g' | + sort -u | + paste -sd '|' - +)" + +if [ -z "$bundled" ]; then + echo "There are no native libraries at the top of $payload." >&2 + echo "A self-contained .NET publish always has some, so this is a publish that went wrong" >&2 + echo "rather than a package with nothing to exclude." >&2 + exit 1 +fi + +work="$(mktemp -d)" +trap 'rm -rf "$work"' EXIT + +mkdir -p "$out" + +# --target rather than trusting the host's arch, because the host's arch here is whatever the runner +# happens to be and the payload's is decided by the RID the publish asked for. They agree today and +# an aarch64 runner would silently make them disagree. +# +# `dist` is emptied deliberately. It is the %{?dist} tag that puts .fc42 in a filename, and it names +# the distribution the package was *built* on — which for a self-contained tree built on Alpine would +# be both meaningless and, if rpm guessed something plausible, actively misleading. +rpmbuild -bb "$repo_root/build/linux/dodossh.spec" \ + --target x86_64 \ + --define "_topdir $work" \ + --define "_rpmdir $out" \ + --define "dist %{nil}" \ + --define "dodo_name $name" \ + --define "dodo_version $rpm_version" \ + --define "dodo_release $rpm_release" \ + --define "dodo_dir $dir" \ + --define "dodo_bin $bin" \ + --define "dodo_title $title" \ + --define "dodo_payload $payload" \ + --define "dodo_bundled $bundled" \ + --define "dodo_icons $repo_root/src/DodoSSH.Client.App/Assets" \ + --define "dodo_licence $repo_root/LICENSE" + +# rpmbuild writes into <_rpmdir>//, and every caller wants the file rather than the tree. +rpm_file="$out/x86_64/$name-$rpm_version-$rpm_release.x86_64.rpm" +if [ ! -s "$rpm_file" ]; then + echo "rpmbuild reported success and there is no package at $rpm_file." >&2 + find "$out" >&2 || true + exit 1 +fi +mv "$rpm_file" "$out/" +rmdir "$out/x86_64" 2>/dev/null || true +rpm_file="$out/$(basename "$rpm_file")" + +echo "==> $(basename "$rpm_file"), $(wc -c < "$rpm_file") bytes" + +# ◆ AND NOW THE PART THAT IS NOT PACKAGING: ASKING THE PACKAGE WHAT IT SAYS ABOUT ITSELF. +# +# Everything above can succeed and still produce something nobody can install, and none of the ways +# that happens are visible in an exit code. Each check below is one of them. + +requires="$(rpm -qp --requires "$rpm_file")" +provides="$(rpm -qp --provides "$rpm_file")" +files="$(rpm -qp --list "$rpm_file")" + +# 1. The bundled-soname trap the spec's provides exclusion sets. If some library under /opt ever +# gains a DT_NEEDED on another library under /opt, elfdeps will put that soname in the requires +# while the exclusion keeps it out of the provides, and the result is a package no host on earth +# can satisfy. Nothing about it looks wrong until an install fails, so it is checked here against +# the actual contents rather than assumed. +carried="$(printf '%s\n' "$files" | sed -n 's|^/opt/.*/\(lib[^/]*\.so[^/]*\)$|\1|p' | sort -u)" +unsatisfiable="" +for soname in $carried; do + if printf '%s\n' "$requires" | grep -qF "$soname("; then + unsatisfiable="$unsatisfiable $soname" + fi +done +if [ -n "$unsatisfiable" ]; then + echo "This package requires sonames it also carries and does not provide:$unsatisfiable" >&2 + echo "That is unsatisfiable on every host, and it means the requires exclusion computed above" >&2 + echo "no longer matches what rpm generated — a bundled library outside the top level, or an rpm" >&2 + echo "that has stopped honouring __requires_exclude. See build/linux/dodossh.spec." >&2 + exit 1 +fi + +# 2. The provides exclusion still doing its job. A package advertising libcoreclr.so to the whole +# system is the thing that exclusion exists to prevent, and a future rpm that stopped honouring +# the macro would break it silently and in the direction nobody looks. +if printf '%s\n' "$provides" | grep -qE '^lib.*\.so'; then + echo "This package advertises bundled libraries to the system:" >&2 + printf '%s\n' "$provides" | grep -E '^lib.*\.so' >&2 + echo "__provides_exclude_from in build/linux/dodossh.spec is meant to stop that." >&2 + exit 1 +fi + +# 3. elfdeps having actually run. If it silently did nothing — a runner image whose rpm was built +# without libelf would do exactly that — the package would carry no glibc requirement at all and +# would install cheerfully onto a host too old to run it. libc.so.6 is the one dependency a +# self-contained .NET tree cannot fail to have. +if ! printf '%s\n' "$requires" | grep -q '^libc\.so\.6'; then + echo "This package does not require libc, which a self-contained .NET tree cannot be true of." >&2 + echo "The automatic ELF scan produced nothing; rpm here is probably built without elfdeps." >&2 + printf '%s\n' "$requires" >&2 + exit 1 +fi + +# 4. The four files a person interacts with, named individually. The tree under /opt is thousands of +# entries and its absence would be caught by size alone; these are the small ones whose absence +# would leave a package that installs, occupies 130 MB and puts nothing on the menu. +for expected in \ + "/opt/$dir/DodoSSH" \ + "/usr/bin/$bin" \ + "/usr/share/applications/$bin.desktop" \ + "/usr/share/icons/hicolor/256x256/apps/$bin.png" +do + if ! printf '%s\n' "$files" | grep -qxF "$expected"; then + echo "The package was built and $expected is not in it." >&2 + printf '%s\n' "$files" | head -40 >&2 + exit 1 + fi +done + +# 5. Nothing world-writable, which on this package is a security property rather than a lint rule. +# The spec sets every mode explicitly and says why; this is the check that it worked, because the +# failure it guards against arrives by inheritance from a publish directory rather than by anybody +# changing this. A world-writable /opt/DodoSSH lets any local account replace the SSH client every +# other account on the machine runs. +# +# Symlinks are excluded because they are always 777 on Linux and the mode means nothing; what the +# link points at is what has permissions. +writable="$( + rpm -qp --queryformat '[%{FILEMODES:perms} %{FILENAMES}\n]' "$rpm_file" | + grep -v '^l' | + awk '$1 ~ /^.{8}w/ { print }' +)" +if [ -n "$writable" ]; then + echo "This package contains world-writable files:" >&2 + printf '%s\n' "$writable" >&2 + echo "Any local account could replace them. See the mode block in build/linux/dodossh.spec." >&2 + exit 1 +fi + +echo "==> requires:" +printf '%s\n' "$requires" | sed 's/^/ /' + +echo "==> $(basename "$rpm_file") is a $name $rpm_version package and says so." + +# The one line the calling job reads. Written to stdout as well as to GITHUB_OUTPUT when there is one, +# so the desk use above still tells you where the file went. +if [ -n "${GITHUB_OUTPUT:-}" ]; then + { + echo "rpm=$rpm_file" + echo "rpmName=$(basename "$rpm_file")" + } >> "$GITHUB_OUTPUT" +fi +echo "$rpm_file" diff --git a/src/DodoSSH.Client.App/Assets/dodossh-128.png b/src/DodoSSH.Client.App/Assets/dodossh-128.png new file mode 100644 index 0000000..e0a29e6 Binary files /dev/null and b/src/DodoSSH.Client.App/Assets/dodossh-128.png differ diff --git a/src/DodoSSH.Client.App/Assets/dodossh-256.png b/src/DodoSSH.Client.App/Assets/dodossh-256.png new file mode 100644 index 0000000..80daf44 Binary files /dev/null and b/src/DodoSSH.Client.App/Assets/dodossh-256.png differ diff --git a/src/DodoSSH.Client.App/Assets/dodossh-48.png b/src/DodoSSH.Client.App/Assets/dodossh-48.png new file mode 100644 index 0000000..1916aa6 Binary files /dev/null and b/src/DodoSSH.Client.App/Assets/dodossh-48.png differ diff --git a/src/DodoSSH.Client.App/Assets/dodossh-64.png b/src/DodoSSH.Client.App/Assets/dodossh-64.png new file mode 100644 index 0000000..74905a7 Binary files /dev/null and b/src/DodoSSH.Client.App/Assets/dodossh-64.png differ diff --git a/src/DodoSSH.Client.App/Assets/dodossh-icon.ps1 b/src/DodoSSH.Client.App/Assets/dodossh-icon.ps1 index d7849cf..4889ea0 100644 --- a/src/DodoSSH.Client.App/Assets/dodossh-icon.ps1 +++ b/src/DodoSSH.Client.App/Assets/dodossh-icon.ps1 @@ -225,3 +225,37 @@ $icnsTarget = Join-Path $PSScriptRoot 'dodossh.icns' $icns.Dispose() Write-Output "Wrote $icnsTarget ($($icnsTypes.Count) entries, $((Get-Item $icnsTarget).Length) bytes)" + +# ---- dodossh-.png, for the Linux desktop entry --------------------------------------------- +# +# The third platform, and the only one that wants the raster loose rather than in a container. A +# freedesktop icon theme is a directory tree — hicolor/48x48/apps/dodossh.png and friends — and the +# RPM built by scripts/ci-linux-rpm.sh installs one file per size into it. +# +# Written here for the reason this whole file exists: one geometry, regenerated in one command. The +# alternative was to have the packaging script pull the 256px frame out of dodossh.ico at build time, +# which works — the .ico's entries really are PNGs — and puts a second place that has to know how an +# ICO directory is laid out. It also silently ties the Linux icon to whatever sizes Windows happens +# to want. +# +# ◆ THE TILE BLEEDS TO THE EDGE HERE, AS IT DOES ON WINDOWS. +# +# tileFraction stays 1.0. The 0.8047 inset above is Apple's icon grid and nothing else — GNOME, KDE +# and every panel that reads hicolor draw the icon at the size they asked for, with no shadow and no +# magnification to leave air for. An inset icon would simply be the small one in the launcher. +# +# ◆ AND THE SIZES ARE NOT A ROUND NUMBER OF THEM. +# +# 48 is the size a GNOME dash and most panels ask for, 128 and 256 are what a large-icon file manager +# and GNOME Software use, and 64 is between them because the 48 upscaled to it is visibly soft. There +# is deliberately nothing below 48: the hairline floor in New-MarkPng keeps a 16px mark legible, but +# no Linux desktop asks this theme for one — window titlebars there draw no icon at all. +$linuxSizes = @(48, 64, 128, 256) + +foreach ($size in $linuxSizes) +{ + [byte[]]$png = New-MarkPng $size + $pngTarget = Join-Path $PSScriptRoot "dodossh-$size.png" + [System.IO.File]::WriteAllBytes($pngTarget, $png) + Write-Output "Wrote $pngTarget ($((Get-Item $pngTarget).Length) bytes)" +}