Public Access
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.
441 lines
32 KiB
Markdown
441 lines
32 KiB
Markdown
# ADR 0013 — Distributing the desktop client, and letting it replace itself
|
|
|
|
- Status: accepted
|
|
- Date: 2026-08-04
|
|
- Builds on: [ADR 0001](0001-e2ee-trust-model.md), [ADR 0011](0011-android-distribution.md)
|
|
- Settles: the desktop half of M4 packaging, which [ADR 0011](0011-android-distribution.md#consequences)
|
|
left open
|
|
|
|
## Context
|
|
|
|
[ADR 0011](0011-android-distribution.md) settled the phone and said explicitly what it was not settling:
|
|
"M4's desktop packaging inherits rule 2 and not the rest." This is that inheritance, plus the thing the
|
|
phone deliberately does not have.
|
|
|
|
The desktop head was not packaged at all. There were no tags, no `Version` property anywhere in the
|
|
repository, and therefore no version: every assembly reported the SDK's default `1.0.0`, and the API had
|
|
been serving that string to every client that asked it for `serverVersion`. There was no installer and no
|
|
way for anybody holding a copy to learn that a newer one existed.
|
|
|
|
That is tolerable while nobody has it. It stops being tolerable at the first release, and it stops in a
|
|
particular direction: this client holds the plaintext of a team's infrastructure credentials, by
|
|
construction (ADR 0001). For software like that, "the fix shipped and the user never got it" is a security
|
|
outcome. ADR 0011 named the cost of shipping outside a store — "no discovery, no automatic update" — and
|
|
accepted it for Android because the platform left no better option. The desktop leaves a better option, and
|
|
this ADR takes it.
|
|
|
|
The new thing an updater introduces is a capability that did not exist before: **something that can replace
|
|
the binary on a user's machine, without the user choosing each build.** ADR 0001 models the party who wants
|
|
the secrets as attacking the client rather than the crypto, and ADR 0011 turns that into a question of
|
|
custody — *which parties can ship one person a build?* An updater is a second answer to that question, so
|
|
it needs the same treatment as the signing key, and it is easy to get wrong because the dangerous part does
|
|
not look like a key.
|
|
|
|
## Decision
|
|
|
|
### 1. Velopack, per-user, under `%LOCALAPPDATA%`, with no administrator prompt
|
|
|
|
`docs/platform-flags.md` had already ruled out the platform-native option, and it is worth restating
|
|
because it is the kind of decision that gets reopened: **MSIX is not deprioritised, it is impossible
|
|
here.** A packaged application runs WebView2 in an AppContainer where loopback connections are blocked
|
|
without a `CheckNetIsolation` exemption, and the terminal data plane *is* a loopback WebSocket. MSIX would
|
|
not degrade the product, it would remove the terminal.
|
|
|
|
Velopack's path was checked against that and reintroduces nothing: `Setup.exe` is an ordinary Win32
|
|
executable that unpacks a directory and creates shortcuts, there is no package manifest and no package
|
|
identity, and the process therefore stays an ordinary desktop process. Manual check 16.4 is what would
|
|
notice if that ever changed, because it connects a real shell from the installed build.
|
|
|
|
### 2. The pack id is `DodoSSH.Desktop`, and it is irreversible
|
|
|
|
Velopack installs to `%LOCALAPPDATA%\<packId>` and **removes that entire directory on uninstall**.
|
|
`ClientPaths.DataDirectory` is `%LOCALAPPDATA%\DodoSSH`, and it holds `cache.db` with its `-wal` and `-shm`
|
|
companions, `settings.json`, and `device.key`.
|
|
|
|
So the obvious pack id would have installed the application on top of the user's encrypted cache, and
|
|
uninstalling would have deleted the device key and the outbox — the changes this machine has made and not
|
|
yet pushed, which `MainWindowViewModel.SignOutWarning` already describes to users as existing "nowhere else
|
|
in the world". The application refuses to delete that without a counted confirmation; an uninstaller would
|
|
have done it silently.
|
|
|
|
Velopack's own guidance is to keep persistent files in roaming `%APPDATA%`. **That guidance is declined**,
|
|
and `ClientPaths` already explains why: two machines writing one SQLite file through a file-sync client
|
|
corrupts it, and the outbox is per-machine by design. The install moves; the profile does not.
|
|
|
|
Like the Android package id, this is a one-way door — it is the identity an installed client matches an
|
|
update against, so changing it later orphans every existing install: still running, never updated, and
|
|
invisible to the new one.
|
|
|
|
### 3. The release is cut by a person, and the forge token never goes near CI
|
|
|
|
This is the paragraph that will be argued with, so it is the one written most carefully.
|
|
|
|
The tempting argument is that a Gitea write token is not a signing key, and so — unlike the Android
|
|
keystore ADR 0011 rule 1 keeps off runners — it could live in a CI secret and let a tag cut a release.
|
|
|
|
**It does not survive contact with what the token can do.** Velopack clients fetch from the configured
|
|
source over TLS and do not verify a package signature when they apply it. So anyone who can write a release
|
|
on this repository can publish an update that every installed client downloads and runs. That is precisely
|
|
the capability ADR 0011 rule 1 places on a machine which is not a runner, reached through a different door —
|
|
and a workflow secret is held by everyone who can change a workflow file, which for a repository with any
|
|
contributors is a wider set than it looks.
|
|
|
|
So: `scripts/release-windows.ps1`, run by a person on a Windows machine, in two phases. Phase one builds and
|
|
packs and stops. Phase two, a separate invocation, asks for the token and uploads. The split exists so that
|
|
what reaches users has been installed and started by a human first, and so that the credential is in memory
|
|
only for the minutes that need it.
|
|
|
|
A second, smaller reason was recorded here — that `vpk` stamps and embeds the `Setup.exe` and
|
|
`Update.exe` stubs with Windows tooling, and every job in `ci.yml` is `runs-on: [linux]` — on the
|
|
reasoning that somebody will fix one reason and believe they are done.
|
|
|
|
◆ **That second reason was not true, and the correction is worth more than the claim was.** `vpk`
|
|
cross-compiles when it is told to: the bracketed OS directive, `vpk [win] pack`, builds the setup stub,
|
|
the portable bundle and the `.nupkg` on Linux, and reports `Directive enabled for cross-compiling from
|
|
Linux (current os) to Windows` while doing it. Only signing needs Windows tooling, and rule 8 above is
|
|
why nothing is signed yet. Plain `vpk pack` on Linux targets Linux and rejects a Windows RID, which is
|
|
the shape of the mistake: the tool does not fail because it cannot, it fails because it was not asked.
|
|
|
|
The correction does not move this rule at all, and that is the point of separating the two. The reason
|
|
that stands was never mechanical, so a Linux runner that *can* pack changes nothing: what may not be on
|
|
a runner is the token, not the build.
|
|
|
|
What CI gains is the same thing the `android` job already does — it proves the artefact still builds and
|
|
still packages. On main and on tags the build job publishes `win-x64` and then packs it, so a restore
|
|
graph that resolves for `net10.0` and not for `net10.0/win-x64` fails there rather than under a person
|
|
midway through a release. Packaging is the more valuable half and adds a few seconds to a publish that
|
|
has already happened — measured at two to seven, against the minutes the publish itself takes: `vpk` verifies
|
|
that the main executable actually calls `VelopackApp.Build().Run()`, so the change that quietly produces
|
|
an application which never updates itself — compiling, tested, and invisible until a release goes
|
|
unreceived — fails in CI. The packages are written to `RUNNER_TEMP` and discarded, and are deliberately
|
|
not offered as workflow artefacts: an installer nobody has run should not sit somewhere that invites
|
|
passing it on.
|
|
|
|
CI does not call `scripts/release-windows.ps1` to do any of this, and should not. That script is the
|
|
person's procedure — it refuses a dirty tree, requires a tagged `HEAD`, fetches the previous release for
|
|
deltas, and prompts for the token — so running it on a runner would mean either weakening it with CI
|
|
switches or having CI satisfy conditions that only make sense at a desk. The workflow writes the same
|
|
`vpk` arguments out in shell, and the constants they share (pack id, title, authors, channel, icon) are
|
|
noted in both places as a contract with `VelopackUpdateChannel` and with every installed client.
|
|
|
|
### 4. The update check points at the project's forge, and the address is a constant
|
|
|
|
ADR 0011 rule 2 carries over unchanged, and its update clause carries with it: "If an update check is ever
|
|
added it points at the project's domain." Here that is `git.dodotech.cloud`, and it is a `const` in
|
|
`VelopackUpdateChannel` rather than a setting.
|
|
|
|
**The constant is the mechanism, not a convention.** A configurable feed address is exactly the knob that
|
|
would let an operator — or an edit to a plaintext `settings.json` — point the update path at the
|
|
deployment, and an operator who can answer "is there a newer version" can answer "no" forever, pinning a
|
|
chosen user to a build with a known hole while holding no key at all. Making it unsettable is that rule
|
|
expressed in a way nobody has to remember.
|
|
|
|
`ClientSettings` therefore stores whether to check, and nothing else: no address, no channel, no token. A
|
|
private release repository is incompatible with this design and that is worth knowing rather than
|
|
discovering, because the token would have to be readable before the vault is unlocked, and this file is the
|
|
one place that can be read then — which is the one place a token may not go.
|
|
|
|
◆ **It was discovered.** The paragraph above was written and the repository was private anyway, so every
|
|
check on every client asked an address that answers `404` — Gitea does not distinguish "not there" from
|
|
"not yours" — and both heads reported the running build as current. It had never once worked, on either
|
|
platform, and nothing anywhere said so. Two things came out of that and both are now written down rather
|
|
than assumed:
|
|
|
|
- **The feed repository must be readable with no credentials, and that is a deployment precondition** rather
|
|
than a property of the code. It is checkable in one line, which is the only reason it is worth stating:
|
|
`curl -so /dev/null -w '%{http_code}' https://git.dodotech.cloud/api/v1/repos/DodoTech-Public/DodoSSH` answers
|
|
`200` when the design holds and `404` when it does not. `/api/v1/version` answering `200` proves only that
|
|
the forge is reachable, which is what made this look like nothing was wrong.
|
|
- **An unreachable channel must be distinguishable from a current build.** `IUpdateChannel.CheckAsync` used
|
|
to document null as the honest answer for both, on the reasoning that the caller does the same thing
|
|
either way. That is true of the six-hourly pass and false of CHECK NOW, and it is what turned a total
|
|
outage into a reassuring sentence. It throws now, and `UpdateViewModel` keeps the distinction it always
|
|
had: silent on the timer, the exception's own message on the button.
|
|
|
|
### 5. `MinClientVersion` stays unread, and if it is ever read it may not fetch
|
|
|
|
`MetaResponse.MinClientVersion` has existed since M1, is served, is fetched on every sign-in, and is read
|
|
by nothing. Hanging the updater off it would have been natural and would have been wrong — and note the
|
|
shape of the error, because it is not "inherits an existing risk". Today the field controls nothing at all.
|
|
The moment an update check is conditioned on it, rule 2's sentence applies verbatim and the risk is
|
|
*created*.
|
|
|
|
The split to preserve:
|
|
|
|
- A deployment may say **"I will not serve a client this old"** and draw a remediation screen. That is the
|
|
deployment describing itself, which is legitimate and is what the field's own documentation asks for.
|
|
That screen may carry a sentence and a link to the project's release page.
|
|
- It may **not** carry a button that triggers a check or a download. A link is the user going somewhere; a
|
|
fetch is the operator's answer steering this process.
|
|
|
|
Under the design as built, an operator withholding or deflating the value achieves nothing — the check runs
|
|
on its own timer and never consults the deployment. Inflating it denies service to their own users, which
|
|
they can already do by turning the server off.
|
|
|
|
### 6. Self-contained, and not single-file
|
|
|
|
Self-contained because .NET 10 is recent enough that almost no machine has the runtime, and because the
|
|
usual objection — that runtime security patches then require an application update — is answered by the
|
|
feature this ADR is about. Velopack's deltas are per-file and the runtime files do not change between our
|
|
releases, so the runtime costs almost nothing per update; it is paid on first install.
|
|
|
|
Not single-file, for four independent reasons: `platform-flags.md` records that libsodium ships native
|
|
binaries per RID and complicates single-file publishing, and `libe_sqlite3`, `libSkiaSharp` and
|
|
`libHarfBuzzSharp` do the same; a bundle changes wholly on every build, so deltas stop working; Velopack is
|
|
a directory-based updater by design; and a self-extracting bundle puts the executable under a temp path
|
|
deep enough to hit the WebView2 long-path failure that same document records. Not trimmed — EF Core is not
|
|
trim-safe and `TreatWarningsAsErrors` turns every `IL2xxx` into a build break, so trimming is a project
|
|
rather than a flag.
|
|
|
|
**Native symbol files are excluded and ours are kept**, which is worth recording as a decision because the
|
|
numbers are so lopsided: `libSkiaSharp.pdb` and `libHarfBuzzSharp.pdb` are 100 MB of debug symbols for
|
|
third-party native code nobody here will step through, and all fifteen of our own managed PDBs together are
|
|
0.93 MB. Dropping the two took a publish from 227 MB to 127 MB. Keeping ours means an
|
|
`Exception.ToString()` carries file names and line numbers, which for a self-hosted product is the whole
|
|
diagnostic channel — the way a fault gets reported is a user pasting a stack into an issue.
|
|
|
|
### 7. One version, derived from the tag
|
|
|
|
MinVer, with `MinVerTagPrefix` of `v`, matching the tags CI already triggers on. The tag was already the
|
|
version of record for the API's container image; this makes it the version of record for everything,
|
|
including the client's own `AssemblyInformationalVersion`, which is what the preferences screen prints and
|
|
what Velopack compares.
|
|
|
|
MinVer's one sharp edge is that it answers plausibly rather than failing: a shallow clone with no tags
|
|
yields `0.0.0-alpha.0.N`. Here a wrong version is a client that never updates, so it is guarded twice —
|
|
`fetch-depth: 0` on every checkout, and a step that fails a tag build when the computed version and the tag
|
|
disagree.
|
|
|
|
The Windows application manifest's `assemblyIdentity` version is **deliberately** left at `1.0.0.0`. It is a
|
|
side-by-side activation field this application does not use and nothing reads; what a person sees comes from
|
|
the PE version resource, which MSBuild fills from `FileVersion`.
|
|
|
|
### 8. Unsigned for now, with a named trigger
|
|
|
|
Every installer will raise SmartScreen's "Windows protected your PC" until reputation accrues. The cost is
|
|
smaller and more precisely bounded than the reflex suggests, and the bound is worth knowing: Mark-of-the-Web
|
|
is applied by the *browser* that downloads `Setup.exe`, so the warning lands at first install only. In-app
|
|
updates are fetched by the application's own HTTP client and applied by `Update.exe` from a local file, and
|
|
never trip it. **One dialog per user per lifetime, not one per update.**
|
|
|
|
The trigger for buying an OV certificate on a hardware token is the first release the README invites a
|
|
stranger to install. ADR 0011 already describes Authenticode as the easy custody case — the key stays with
|
|
the developer — and the post-2023 requirement that it live on FIPS 140-2 Level 2 hardware enforces "offline,
|
|
never in CI" physically rather than by policy. `--signParams` is the single line in the release script that
|
|
changes.
|
|
|
|
**Azure Trusted Signing is not the quiet default and needs its own ADR.** It is cheaper and has no hardware
|
|
token, and it puts a compellable third party in the signing path — which is ADR 0011 rule 3's shape one
|
|
layer down, declined there for reasons that do not stop applying because the vendor changed.
|
|
|
|
**This rule is Windows-only, and macOS gets the opposite one.** See decision 10: there is no "unsigned for
|
|
now" available on that platform at any price, because Gatekeeper refuses rather than warns.
|
|
|
|
### 10. macOS is a second desktop platform on the same machinery, signed from the start
|
|
|
|
The macOS head is the same application, the same Velopack, and the same two-phase person-run release. Four
|
|
things differ, and each is forced rather than chosen.
|
|
|
|
**Signing is a precondition, not an improvement.** Decision 8's whole argument — one dialog per user per
|
|
lifetime, buy a certificate when a stranger is invited to install — has no macOS equivalent. An
|
|
un-notarized download is refused outright, so the Developer ID certificate and the notarization round trip
|
|
are the price of the package existing. `scripts/release-macos.sh` therefore refuses to run without the
|
|
signing identities, where the Windows script refuses nothing.
|
|
|
|
**The channels are `osx` and `osx-nightly`, and they are separate for decision 9's reason.** Four channels
|
|
now publish to one repository, and the only thing keeping a Mac from being offered a Windows package is
|
|
that it never reads that index. The macOS nightly channel is named and has no publisher: CI builds and
|
|
bundles the macOS head to prove it still builds, and uploads nothing, exactly as it does for the Windows
|
|
release channel.
|
|
|
|
**The pack id is shared with Windows, and on macOS it is visible.** vpk names the bundle after the pack id,
|
|
so `/Applications` holds `DodoSSH.Desktop.app`. Decision 2's reasoning applies with more force here rather
|
|
than less: a pack id of `DodoSSH` would put Velopack's install root on `~/Library/Application
|
|
Support/DodoSSH`, which is `ClientPaths.DataDirectory`, and an uninstall would take the user's un-synced
|
|
outbox with it. `CFBundleDisplayName` puts the product name back in front of a person; the directory keeps
|
|
the id.
|
|
|
|
**arm64 only, because the check is the scarce thing.** Velopack keys a channel to one architecture, and an
|
|
Intel package would be the only artefact in this repository reaching users without somebody having walked
|
|
Phase 18 against it. The engineering for a second channel is small and is described in the release script;
|
|
what is missing is an Intel Mac to verify on, and shipping blind is the thing this project's manual-check
|
|
discipline exists to refuse.
|
|
|
|
**And one thing that does not differ, which is worth saying because it is the expensive half.** The
|
|
capability to publish still lives on a person's machine and never in CI. Notarization does not change that:
|
|
Apple's ticket says this build came from this developer account, and says nothing about whether the build
|
|
should have been made. Velopack clients still apply what their feed serves. Rule 3 is untouched.
|
|
|
|
### 9. There is a second desktop channel, published by CI, and it is a second application
|
|
|
|
[ADR 0014](0014-android-updates.md) gave the phone a nightly channel and rule 3 above gives the desktop
|
|
none, which left the two heads with different answers to the same question — *how does somebody try what
|
|
is on main?* — for no reason other than the order the work happened in. This is the desktop's answer, and
|
|
it is the phone's arrangement with one difference that changes how much of it has to be built.
|
|
|
|
**Android gets the separation from the platform. Windows does not.** Two Android channels cannot replace
|
|
one another because the installer refuses a package signed by a different key; a mistake there is loud.
|
|
Velopack applies what its feed serves and verifies no signature, so on this head the separation is entirely
|
|
construction:
|
|
|
|
| | release | nightly |
|
|
| --- | --- | --- |
|
|
| pack id | `DodoSSH.Desktop` | `DodoSSH.Desktop.Nightly` |
|
|
| Velopack channel | `win` | `win-nightly` |
|
|
| feed | newest non-prerelease release | the rolling `nightly-desktop` prerelease |
|
|
| profile | `%LOCALAPPDATA%\DodoSSH` | `%LOCALAPPDATA%\DodoSSH.Nightly` |
|
|
| cut by | a person, from a `v*` tag | CI, on every push to main |
|
|
|
|
Four separations rather than one, because each closes a different door. The pack id decides the install
|
|
directory and what an installed client matches an update against, so it is what makes a nightly install
|
|
*beside* rather than *over*. The channel names the index file on the feed, so neither build ever reads the
|
|
other's — and the release channel additionally refuses prereleases, which is belt and braces in the one
|
|
direction that matters: an unsigned CI build must never reach a machine somebody is trusting with their
|
|
credentials. The profile is the one that is easy to skip and would hurt most: the cache schema is migrated
|
|
on every launch, before unlock, so a shared profile means a nightly quietly upgrading a database the
|
|
release build then opens.
|
|
|
|
**The prerelease flag is also how the two heads stay out of each other's way.** The phone's release channel
|
|
reads `releases/latest`, which skips prereleases. A desktop nightly published as a stable release would
|
|
become the newest release in this repository, and every phone on the release channel would start failing
|
|
its check against a release carrying no Android manifest. The nightly is published with `--pre` for its own
|
|
sake and for that one.
|
|
|
|
**What this costs, stated where somebody will read it before installing:** whoever can write a release on
|
|
this repository can put a build on every nightly desktop, because the client applies what the feed serves
|
|
without verifying a signature. That set includes CI and therefore everyone who can change a workflow file.
|
|
It is the same trade [ADR 0014](0014-android-updates.md) accepted for the phone's nightly, and it is
|
|
acceptable for the same reason and only that reason: this is not the channel anybody's real credentials are
|
|
on. Rule 3 is untouched — the release channel still has no job, no token and no runner.
|
|
|
|
**The version gets a floor, on this channel only.** MinVer answers `0.0.0-alpha.0.N` until the first `v*`
|
|
tag and `vpk` refuses to pack anything below `0.0.1`, so the nightly job lifts the patch digit and keeps
|
|
the prerelease height. It is applied through `MinVerVersionOverride`, which moves the assembly version too
|
|
— packing a number the assemblies disagree with would put one version in the installer and another on the
|
|
preferences screen, which is the screen somebody reads when asked which nightly they are running. Ordering
|
|
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
|
|
told where the release page is — but from the first install onward the client keeps itself current without
|
|
anybody deciding to go and look. That is the sharpest edge of the Android decision, blunted on the platform
|
|
where it could be.
|
|
|
|
**Nothing is ever installed while the application is running.** A fetched update waits for a restart the
|
|
user presses, or for the next launch they were going to do anyway. This is not caution for its own sake:
|
|
this application deliberately keeps shells running across a lock, and argues in `LockAsync` that a lock
|
|
which destroyed work would simply stop being used. A restart does end every shell, so it is a decision that
|
|
belongs to the person whose job is running in one — and the interface says so, in the same words the close
|
|
button already uses.
|
|
|
|
**The release is a manual step, and that will feel slow.** It is roughly ten minutes of a person's attention
|
|
per release, and the convenience being refused is the entire point of CI. Where the two collide the custody
|
|
argument wins, exactly as it does for the Android keystore.
|
|
|
|
**The forge grows by about 125 MB per release, forever.** Measured on the first real pack rather than
|
|
estimated: a 127 MB publish directory compresses to a 60 MB full `.nupkg` and a 64 MB `Setup.exe`, and both
|
|
are uploaded. Velopack needs the previous full package present to build a delta against, so pruning old
|
|
releases has a real cost — a client several versions behind falls back to a full download. A 64 MB asset is
|
|
also large enough to meet whatever body-size limit fronts `git.dodotech.cloud` (nginx's
|
|
`client_max_body_size` defaults to 1 MB), which is worth checking before the first upload rather than
|
|
discovering after ten minutes of transfer.
|
|
|
|
**Uninstalling leaves the vault cache behind, on purpose.** `%LOCALAPPDATA%\DodoSSH` survives, so a
|
|
reinstall finds an enrolled machine and asks for the passphrase — which is what somebody reinstalling wants.
|
|
The honest cost is that `device.key` is left too, and the account goes on listing a device that is no longer
|
|
installed. That is a wart rather than a hole: the blob is decryptable only by this machine's TPM, and Sign
|
|
Out is the button that withdraws the registration. An uninstall is not a sign-out and must not pretend to
|
|
be one.
|
|
|
|
**Contracts and client now share a version number.** `DodoSSH.Contracts` is packable, so a contracts-only
|
|
change ships as a client version bump. One number is the point; this is the price of it.
|
|
|
|
**The Android head's `versionName` now follows the same tag**, while its integer `versionCode` stays
|
|
hand-bumped, because Android needs a monotonic integer and SemVer does not provide one. Nothing else about
|
|
ADR 0011 changes.
|
|
|
|
## Rejected
|
|
|
|
- **MSIX.** Not a preference. It would break the terminal outright; see decision 1.
|
|
- **A CI job that cuts the release on a tag.** The convenience is the point of CI and the custody is the
|
|
point of ADR 0011; where they collide the custody wins. See decision 3, and note that a Windows runner
|
|
answers the mechanical objection while leaving the real one untouched.
|
|
- **A configurable update feed.** Named here because it is the change somebody will propose in good faith,
|
|
for a self-hosted product where making things configurable is usually right. It is the one setting that
|
|
would hand the operator the capability the whole trust model is about.
|
|
- **The deployment serving the installer.** Refused again, for the third ADR running, because it is
|
|
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
|
|
one number is a pair that can disagree. MinVer's silent-wrong-answer failure mode is real and is what the
|
|
two CI guards in decision 7 exist for.
|